Initial port of the tool from the internal repository to the open. (#492)

* Initial port of the tool from the internal repository to the open.

* Add CI template.

* Update the CI file per feedback.
This commit is contained in:
Alex Ghiondea 2020-04-15 15:41:33 -07:00 коммит произвёл GitHub
Родитель 624e8fc1b6
Коммит 5333061f0e
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
24 изменённых файлов: 1623 добавлений и 0 удалений

264
tools/github-issues/.gitignore поставляемый Normal file
Просмотреть файл

@ -0,0 +1,264 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
# Azure Functions localsettings file
local.settings.json
# User-specific files
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
# Visual Studio 2015 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUNIT
*.VisualState.xml
TestResult.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# DNX
project.lock.json
project.fragment.lock.json
artifacts/
*_i.c
*_p.c
*_i.h
*.ilk
*.meta
*.obj
*.pch
*.pdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# JustCode is a .NET coding add-in
.JustCode
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# TODO: Comment the next line if you want to checkin your web deploy settings
# but database connection strings (with potential passwords) will be unencrypted
#*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# The packages folder can be ignored because of Package Restore
**/packages/*
# except build/, which is used as an MSBuild target.
!**/packages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/packages/repositories.config
# NuGet v3's project.json files produces more ignoreable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
node_modules/
orleans.codegen.cs
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
# SQL Server files
*.mdf
*.ldf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# JetBrains Rider
.idea/
*.sln.iml
# CodeRush
.cr/
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc

Просмотреть файл

@ -0,0 +1,30 @@
trigger:
branches:
include:
- master
- feature/*
- release/*
- hotfix/*
paths:
include:
- tools/github-issues
pr:
branches:
include:
- master
- feature/*
- release/*
- hotfix/*
paths:
include:
- tools/github-issues
extends:
template: ../../eng/pipelines/templates/stages/archetype-sdk-tool-azure-function.yml
parameters:
ToolName: github-issues
FunctionProject: Azure.Sdk.Tools.GitHubIssues
TestProject: Azure.Sdk.Tools.GitHubIssues.Tests
ProductionEnvironmentName: githubissuesprod
StagingEnvironmentName: githubissuesstaging

Просмотреть файл

@ -0,0 +1,25 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.29926.136
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "github-issues", "..\src\github-issues.csproj", "{B4196DA5-3FAF-41C5-AF89-456541F06355}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{B4196DA5-3FAF-41C5-AF89-456541F06355}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B4196DA5-3FAF-41C5-AF89-456541F06355}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B4196DA5-3FAF-41C5-AF89-456541F06355}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B4196DA5-3FAF-41C5-AF89-456541F06355}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {697AC08F-6FB4-4B2E-9C03-D7EF89F9433C}
EndGlobalSection
EndGlobal

Просмотреть файл

@ -0,0 +1,34 @@
using CommandLine.Attributes;
using GitHubIssues;
using System.Collections.Generic;
namespace Creator
{
internal class CmdLineArgs
{
[OptionalArgument(null, "token", "The GitHub authentication token. If not specified, we will authorize to GitHub.")]
public string Token { get; set; }
[OptionalArgument(null, "emailToken", "The SendGrid authentication token. If not specified, no email will be sent")]
public string EmailToken { get; set; }
[OptionalArgument(null, "repositories", "The list of repositories where to add the milestones to. The format is: owner\\repoName\\email,email,email;owner\\repoName\\email,email,email")]
public string Repositories { get; set; }
[OptionalArgument(null, "from", "The email address to use when sending the email")]
public string FromEmail { get; set; }
public IEnumerable<RepositoryConfig> RepositoriesList => ParseRepositories(Repositories);
private IEnumerable<RepositoryConfig> ParseRepositories(string repositories)
{
string[] repos = repositories.Split(';');
foreach (var repo in repos)
{
yield return RepositoryConfig.Create(repo);
}
}
}
}

Просмотреть файл

@ -0,0 +1,21 @@
namespace GitHubIssues
{
internal static class Constants
{
public static class Labels
{
public static string NeedsAttention = "Needs: Attention";
public static string CustomerReported = "customer-reported";
public static string Bug = "bug";
public static string Feature = "feature-request";
public static string Question = "question";
public static string Client = "Client";
public static string Mgmt = "Mgmt";
public static string Service = "Service";
public static string ServiceAttention = "Service Attention";
public static string EngSys = "EngSys";
public static string MgmtEngSys = "Mgmt-EngSys";
}
}
}

Просмотреть файл

@ -0,0 +1,42 @@
using SendGrid;
namespace GitHubIssues
{
internal static class EmailSender
{
public static void SendEmail(string emailToken, string template, string from, string[] to, string[] cc, string title)
{
SendGrid.SendGridClient client = new SendGrid.SendGridClient(emailToken);
SendGrid.Helpers.Mail.SendGridMessage message = new SendGrid.Helpers.Mail.SendGridMessage();
message.SetFrom(from);
foreach (var item in to)
{
if (!string.IsNullOrEmpty(item))
{
message.AddTo(item);
}
}
foreach (var item in cc)
{
if (!string.IsNullOrEmpty(item))
{
message.AddCc(item);
}
}
message.SetSubject($"GitHub Report: {title}");
message.AddContent(MimeType.Html, template);
#if !DEBUG
// Don't accidentally send email
var emailResult = client.SendEmailAsync(message).GetAwaiter().GetResult();
#else
System.IO.File.WriteAllText("output.html", template);
#endif
}
}
}

Просмотреть файл

@ -0,0 +1,58 @@
using Octokit;
using OutputColorizer;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace GitHubIssues.Helpers
{
internal static class GitHubHelpers
{
public static GitHubClient CreateGitHubClient(string token)
{
GitHubClient ghClient = new GitHubClient(new ProductHeaderValue("github-issues"))
{
Credentials = new Credentials(token)
};
return ghClient;
}
public static IEnumerable<Issue> SearchForGitHubIssues(this GitHubClient s_gitHub, SearchIssuesRequest issueQuery)
{
List<Issue> totalIssues = new List<Issue>();
int totalPages = -1, currentPage = 0;
do
{
currentPage++;
issueQuery.Page = currentPage;
SearchIssuesResult searchresults = null;
searchresults = s_gitHub.Search.SearchIssues(issueQuery).Result;
foreach (var item in searchresults.Items)
{
totalIssues.Add(item);
Colorizer.WriteLine($"Found issue '[Cyan!{item.HtmlUrl}]' in GitHub.");
Colorizer.WriteLine($" Labels: '[Green!{string.Join(",", item.Labels.Select(x => x.Name).OrderBy(s => s))}]'.");
}
// if this is the first call, setup the totalpages stuff
if (totalPages == -1)
{
totalPages = (searchresults.TotalCount / 100) + 1;
}
} while (totalPages > currentPage);
return totalIssues;
}
public static async Task<IEnumerable<Milestone>> ListMilestones(this GitHubClient s_gitHub, RepositoryConfig repo)
{
MilestonesClient ms = new MilestonesClient(new ApiConnection(s_gitHub.Connection));
return (await ms.GetAllForRepository(repo.Owner, repo.Repo)).ToList();
}
}
}

Просмотреть файл

@ -0,0 +1,24 @@
using System.IO;
using System.Text;
namespace GitHubIssues.Helpers
{
internal static class StreamHelpers
{
public static Stream GetStreamForString(string s)
{
MemoryStream ms = new MemoryStream();
byte[] content = Encoding.ASCII.GetBytes(s);
ms.Write(content, 0, content.Length);
ms.Position = 0;
return ms;
}
public static string GetContentAsString(Stream s)
{
using var sr = new StreamReader(s);
return sr.ReadToEnd();
}
}
}

Просмотреть файл

@ -0,0 +1,66 @@
using System.Text;
namespace GitHubIssues.Html
{
public class HtmlPageCreator
{
private readonly string _title;
public HtmlPageCreator(string title)
{
_title = title;
}
private readonly StringBuilder _content = new StringBuilder();
private const string Header = @"<!DOCTYPE html>
<html lang=""en"" xmlns=""http://www.w3.org/1999/xhtml"">
<head>
<meta charset = ""utf-8"" />
<title>##title##</title>
<style>##css##</style>
</head>
<body>";
private const string Footer = @"</body>
</html>";
private const string CSS = @"
table {
font-family: ""Calibri"";
border-collapse: collapse;
width: 100%;
}
th, td {
text-align: left;
padding: 8px;
}
tr:nth-child(even){background-color: #e5f1f9}
th {
background-color: #0078ca;
color: white;
}
";
public void AddContent(string content)
{
_content.Append(content);
}
public string GetContent()
{
StringBuilder email = new StringBuilder();
email.Append(Header);
email.Replace("##title##", _title);
email.Replace("##css##", CSS);
email.Append(_content);
email.Append(Footer);
return email.ToString();
}
}
}

Просмотреть файл

@ -0,0 +1,94 @@
using GitHubIssues.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace GitHubIssues.Helpers
{
public class TableCreator
{
private string _header;
public TableCreator(string header)
{
_header = header;
}
public static class Templates
{
public static readonly Func<ReportIssue, string> Title = i => $"<a href=\"{i.Issue.HtmlUrl}\">{i.Issue.Title}</a>";
public static readonly Func<ReportIssue, string> Labels = i => string.Join(',', i.Issue.Labels.Select(l => l.Name));
public static readonly Func<ReportIssue, string> Author = i => i.Issue.User.Login;
public static readonly Func<ReportIssue, string> Assigned = i => i.Issue.Assignee?.Login;
public static readonly Func<ReportIssue, string> Milestone = i => $"<a href=\"{i.Milestone?.HtmlUrl}\">{i.Milestone?.Title}</a>";
}
private Dictionary<string, Func<ReportIssue, string>> _formatActions = new Dictionary<string, Func<ReportIssue, string>>();
public void DefineTableColumn(string header, Func<ReportIssue, string> action) => _formatActions.Add(header, action);
/// <summary>
/// Creates an html representation of the list
/// </summary>
/// <param name="issues"></param>
/// <returns></returns>
public string GetContent(IEnumerable<ReportIssue> issues)
{
string _headerRow = CreateHeaderRow(_formatActions.Keys);
string _templateRow = CreateTemplateRow(_formatActions.Keys.Count);
StringBuilder formattedTable = new StringBuilder();
formattedTable.Append($"<h2>{_header}</h2>");
formattedTable.Append($"<p>Found {issues.Count()} issue(s).</p>");
// add a scrollable div around the table
formattedTable.Append(@"<div style=""overflow-x:auto;"">"); // start-scrollable-div
formattedTable.Append("<table>"); //start-table
formattedTable.Append(_headerRow);
foreach (ReportIssue issue in issues)
{
List<string> args = new List<string>();
foreach (string header in _formatActions.Keys)
{
string valueForHeader = _formatActions[header](issue);
args.Add(valueForHeader);
}
formattedTable.AppendFormat(_templateRow, args.ToArray());
}
formattedTable.Append("</table>"); // end-table
formattedTable.Append("</div>"); // end-scrollable-div
return formattedTable.ToString();
}
private static string CreateHeaderRow(IEnumerable<string> headers)
{
StringBuilder headerRow = new StringBuilder();
headerRow.Append("<tr>");
foreach (string header in headers)
{
headerRow.Append("<th>");
headerRow.Append(header);
headerRow.Append("</th>");
}
headerRow.Append("</tr>");
return headerRow.ToString();
}
private static string CreateTemplateRow(int columnCount)
{
StringBuilder headerRow = new StringBuilder();
headerRow.Append("<tr>");
for (int i = 0; i < columnCount; i++)
{
headerRow.Append("<td>{");
headerRow.Append(i);
headerRow.Append("}</td>");
}
headerRow.Append("</tr>");
return headerRow.ToString();
}
}
}

Просмотреть файл

@ -0,0 +1,13 @@
using Octokit;
namespace GitHubIssues.Models
{
public class ReportIssue
{
public Issue Issue { get; set; }
public Milestone Milestone { get; set; }
public string Note { get; set; }
}
}

Просмотреть файл

@ -0,0 +1,69 @@
namespace GitHubIssues
{
internal class RepositoryConfig
{
public string Owner { get; set; }
public string Repo { get; set; }
public string[] ToEmail { get; set; }
public string[] CcEmail { get; set; }
public static RepositoryConfig Create(string entry)
{
// the entry looks like:
// repo\owner\to:alias,alias#cc:alias
// normalize the slashes.
entry = entry.Replace('/', '\\');
string[] entries = entry.Split('\\');
// parse the emails.
string[] emails = entries[2].Split("#");
string to = string.Empty, cc = string.Empty;
for (int i = 0; i < emails.Length; i++)
{
if (emails[i].StartsWith("to"))
{
to += emails[i];
}
else if (emails[i].StartsWith("cc"))
{
cc += emails[i];
}
}
RepositoryConfig config = new RepositoryConfig
{
Owner = entries[0],
Repo = entries[1],
ToEmail = ParseEmails(to),
CcEmail = ParseEmails(cc)
};
return config;
}
private static string[] ParseEmails(string aliasList)
{
// aliasList looks like: to:alias,alias or cc:alias,alias
aliasList = aliasList.Substring(aliasList.IndexOf(":") + 1);
string[] aliases = aliasList.Split(",");
// the assumption is that all email addresses are going to Microsoft.
for (int i = 0; i < aliases.Length; i++)
{
aliases[i] = aliases[i] + "@microsoft.com";
}
return aliases;
}
}
}

Просмотреть файл

@ -0,0 +1,26 @@
using GitHubIssues.Reports;
using Microsoft.Azure.WebJobs;
using Microsoft.Extensions.Logging;
namespace FindNewItems
{
public partial class Program
{
#if DEBUG
static private volatile bool hasExecuted = false;
[FunctionName("DebugFunction")]
public static void DebugThis([TimerTrigger("* * * * * *")]TimerInfo myTimer, ILogger log)
{
if (!hasExecuted)
{
new FindIssuesInBacklogMilestones(log).Execute();
hasExecuted = true;
}
else
{
log.LogError("The function has executed already!");
}
}
#endif
}
}

Просмотреть файл

@ -0,0 +1,53 @@
using GitHubIssues.Reports;
using Microsoft.Azure.WebJobs;
using Microsoft.Extensions.Logging;
namespace FindNewItems
{
public partial class Program
{
#if !DEBUG
[FunctionName("FindNewGitHubIssuesAndPRs_7am")]
public static void FindNewGitHubIssuesAndPRs_AM([TimerTrigger("0 30 14 * * *")]TimerInfo myTimer, ILogger log)
{
// Run at 14:30 UTC
new FindNewGitHubIssuesAndPRs(log).Execute();
}
[FunctionName("FindNewGitHubIssuesAndPRs_2pm")]
public static void FindNewGitHubIssuesAndPRs_PM([TimerTrigger("0 30 21 * * *")]TimerInfo myTimer, ILogger log)
{
// Run at 21:30 UTC
new FindNewGitHubIssuesAndPRs(log).Execute();
}
[FunctionName("FindIssuesInPastDueMilestones")]
public static void FindIssuesInPastDueMilestones([TimerTrigger("0 0 15 * * WED")]TimerInfo myTimer, ILogger log)
{
// Run at 15:00 UTC on Wednesday morning.
new FindIssuesInPastDueMilestones(log).Execute();
}
[FunctionName("FindIssuesInBacklogMilestones")]
public static void FindIssuesInBacklogMilestones([TimerTrigger("0 30 15 1 * *")]TimerInfo myTimer, ILogger log)
{
// Run at 15:30 UTC on the first of the month.
new FindIssuesInBacklogMilestones(log).Execute();
}
[FunctionName("FindCustomerReportedIssuesInInvalidState")]
public static void FindCustomerReportedIssuesInInvalidState([TimerTrigger("0 0 16 * * MON")]TimerInfo myTimer, ILogger log)
{
// Run at 16:00 UTC every Monday.
new FindCustomerRelatedIssuesInvalidState(log).Execute();
}
[FunctionName("FindPRsOlderThan3Months")]
public static void FindPRsOlderThan3Months([TimerTrigger("0 30 15 * * MON")]TimerInfo myTimer, ILogger log)
{
// Runs at 7:30AM on Monday morning.
new FindStalePRs(log).Execute();
}
#endif
}
}

Просмотреть файл

@ -0,0 +1,41 @@
using CommandLine;
using Creator;
using GitHubIssues.Helpers;
using Microsoft.Extensions.Logging;
using Octokit;
using System;
namespace GitHubIssues
{
abstract class BaseFunction
{
protected GitHubClient _gitHub;
protected CmdLineArgs _cmdLine;
protected ILogger _log;
private static readonly ParserOptions s_parserOptions = new ParserOptions()
{
VariableNamePrefix = "parser_",
LogParseErrorToConsole = true
};
public BaseFunction(ILogger log)
{
_log = log;
_log.LogInformation($"C# Timer trigger function executed at: {DateTime.Now}");
if (!Parser.TryParse(new string[0], out _cmdLine, s_parserOptions))
{
return;
}
log.LogInformation("Command Line arguments parsed!");
// we have to get the token.
_gitHub = GitHubHelpers.CreateGitHubClient(_cmdLine.Token);
log.LogInformation("Created GitHub client");
}
public abstract void Execute();
}
}

Просмотреть файл

@ -0,0 +1,283 @@
using GitHubIssues.Helpers;
using GitHubIssues.Html;
using GitHubIssues.Models;
using Microsoft.Extensions.Logging;
using Octokit;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace GitHubIssues.Reports
{
internal partial class FindCustomerRelatedIssuesInvalidState : BaseFunction
{
public FindCustomerRelatedIssuesInvalidState(ILogger log) : base(log)
{
}
public override void Execute()
{
foreach (var repositoryConfig in _cmdLine.RepositoriesList)
{
HtmlPageCreator emailBody = new HtmlPageCreator($"Customer reported issues with invalid state in {repositoryConfig.Repo}");
bool hasFoundIssues = ValidateCustomerReportedIssues(repositoryConfig, emailBody);
if (hasFoundIssues)
{
// send the email
EmailSender.SendEmail(_cmdLine.EmailToken, _cmdLine.FromEmail, emailBody.GetContent(), repositoryConfig.ToEmail, repositoryConfig.CcEmail,
$"Customer reported issues in invalid state in repo {repositoryConfig.Repo}");
}
}
}
private bool ValidateCustomerReportedIssues(RepositoryConfig repositoryConfig, HtmlPageCreator emailBody)
{
TableCreator tc = new TableCreator("Customer reported issues");
tc.DefineTableColumn("Title", TableCreator.Templates.Title);
tc.DefineTableColumn("Labels", TableCreator.Templates.Labels);
tc.DefineTableColumn("Author", TableCreator.Templates.Author);
tc.DefineTableColumn("Assigned", TableCreator.Templates.Assigned);
tc.DefineTableColumn("Issues Found", i => i.Note);
List<ReportIssue> issuesWithNotes = new List<ReportIssue>();
foreach (var issue in _gitHub.SearchForGitHubIssues(CreateQuery(repositoryConfig)))
{
if (!ValidateIssue(issue, out string issuesFound))
{
issuesWithNotes.Add(new ReportIssue() { Issue = issue, Note = issuesFound });
_log.LogWarning($"{issue.Number}: {issuesFound}");
}
}
// order the issues by the list of issues they have.
issuesWithNotes = issuesWithNotes.OrderBy(i => i.Note).ToList();
emailBody.AddContent(tc.GetContent(issuesWithNotes));
return issuesWithNotes.Any();
}
private bool ValidateIssue(Issue issue, out string issuesFound)
{
// This validated that an issue has the common fields correctly setup
// The issue has only one of the 'bug', 'feature-request' and 'question'
// The issue has an owner assigned to it (if not in the backlog milestone)
// The issue has the appropriate service-level labels
// - Service attention requires a service label to be set
// - If 'Service' is set, service attention is required
// The issue has the appropriate milestone set
// - A question cannot be in the backlog milestone
// - A bug/feature-request need to have a valid milestone associated with them
StringBuilder problemsWithTheIssue = new StringBuilder();
bool isValidIssue = true;
isValidIssue &= ValidateIssueType(issue, problemsWithTheIssue);
isValidIssue &= ValidateIssueAssignee(issue, problemsWithTheIssue);
isValidIssue &= ValidateServiceLabels(issue, problemsWithTheIssue);
isValidIssue &= ValidateMilestone(issue, problemsWithTheIssue);
issuesFound = problemsWithTheIssue.ToString();
return isValidIssue;
}
private bool ValidateIssueType(Issue issue, StringBuilder problemsWithTheIssue)
{
IssueType issueType = GetIssueType(issue.Labels);
// The issue should have one of the 'bug', 'feature-request' or 'question' label
if ((issueType & (issueType - 1)) != 0)
{
problemsWithTheIssue.Append("The issue must have **just** one of the 'bug', 'feature-request' or 'question' labels. ");
return false;
}
// the issue should have at least one of the 'bug', 'feature-request' and 'question'
if (issueType == IssueType.None)
{
problemsWithTheIssue.Append("The issue must have one of the 'bug', 'feature-request' or 'question' labels. ");
return false;
}
return true;
}
private bool ValidateIssueAssignee(Issue issue, StringBuilder problemsWithTheIssue)
{
if (issue.Assignee == null)
{
problemsWithTheIssue.Append("The issue must be assigned to an owner. ");
return false;
}
return true;
}
private bool ValidateServiceLabels(Issue issue, StringBuilder problemsWithTheIssue)
{
// - Service attention requires a service label to be set
// - If 'Service' is set, service attention is required
ImpactArea impactedArea = GetImpactArea(issue.Labels);
// has service attention
bool hasServiceAttentionLabel = issue.Labels.Any(i => StringComparer.OrdinalIgnoreCase.Equals(i.Name, Constants.Labels.ServiceAttention));
if (!hasServiceAttentionLabel && impactedArea.HasFlag(ImpactArea.Service))
{
problemsWithTheIssue.Append("The Azure SDK team does not own any issues in the Service. ");
return false;
}
bool hasServiceLabel = issue.Labels.Any(i => i.Color == "e99695" && i.Name != Constants.Labels.ServiceAttention);
// check to see if it has a service label if service attention is set
if (hasServiceAttentionLabel && !hasServiceLabel)
{
problemsWithTheIssue.Append("The issue needs a service label. ");
return false;
}
// check to see if the issue has an ownership (one of Client, Mgmt, Service)
if (impactedArea == ImpactArea.None)
{
problemsWithTheIssue.Append("The issue needs an impacted area (i.e. Client, Mgmt or Service). ");
return false;
}
// the issue should not have more than 1 type of labels to indicate the type.
if ((impactedArea & (impactedArea - 1)) != 0)
{
problemsWithTheIssue.Append("The impacted area must have just one of the 'Client', 'Mgmt', 'Service', 'EngSys' and 'EngSys-Mgmt' labels. ");
return false;
}
return true;
}
/// <summary>
/// Validates that the milestone is correctly specified.
/// </summary>
/// <param name="issue"></param>
/// <param name="problemsWithTheIssue"></param>
/// <returns></returns>
private bool ValidateMilestone(Issue issue, StringBuilder problemsWithTheIssue)
{
Milestone issueMilestone = issue.Milestone;
IssueType issueType = GetIssueType(issue.Labels);
if (issueType == IssueType.None)
{
// There is nothing to say here because we don't know if this is a bug of question at this point.
return false;
}
// If the milestone is not set and we don't have a question
if (issueMilestone == null && issueType != IssueType.Question)
{
problemsWithTheIssue.Append("A 'bug' or 'feature-request' must be assigned to a milestone. ");
return false;
}
// If we are not looking at a question
if (issueType != IssueType.Question)
{
// Is the milestone closed?
if (issue.Milestone.State == ItemState.Closed)
{
problemsWithTheIssue.Append("The issue must be assigned to an active milestone. ");
return false;
}
else
{
// Is the milestone's due-date in the past?
if (issue.Milestone.DueOn != null && issue.Milestone.DueOn < DateTimeOffset.Now)
{
problemsWithTheIssue.Append("The issue must be assigned to an active milestone that is not past due. ");
return false;
}
}
}
else
{
// for questions we should make sure they are not parked in the backlog milestone
if (issue.Milestone != null && issue.Milestone.DueOn == null)
{
problemsWithTheIssue.Append("A 'question' should not be assigned to a milestone without an end-date. ");
return false;
}
}
return true;
}
private IssueType GetIssueType(IReadOnlyList<Label> labels)
{
IssueType type = IssueType.None;
foreach (Label label in labels)
{
if (StringComparer.OrdinalIgnoreCase.Equals(label.Name, Constants.Labels.Bug))
{
type |= IssueType.Bug;
}
if (StringComparer.OrdinalIgnoreCase.Equals(label.Name, Constants.Labels.Feature))
{
type |= IssueType.Feature;
}
if (StringComparer.OrdinalIgnoreCase.Equals(label.Name, Constants.Labels.Question))
{
type |= IssueType.Question;
}
}
return type;
}
private ImpactArea GetImpactArea(IReadOnlyList<Label> labels)
{
ImpactArea area = ImpactArea.None;
foreach (Label label in labels)
{
if (StringComparer.OrdinalIgnoreCase.Equals(label.Name, Constants.Labels.Client))
{
area |= ImpactArea.Client;
}
if (StringComparer.OrdinalIgnoreCase.Equals(label.Name, Constants.Labels.Mgmt))
{
area |= ImpactArea.Mgmt;
}
if (StringComparer.OrdinalIgnoreCase.Equals(label.Name, Constants.Labels.Service))
{
area |= ImpactArea.Service;
}
if (StringComparer.OrdinalIgnoreCase.Equals(label.Name, Constants.Labels.EngSys))
{
area |= ImpactArea.EngSys;
}
if (StringComparer.OrdinalIgnoreCase.Equals(label.Name, Constants.Labels.MgmtEngSys))
{
area |= ImpactArea.MgmtEngSys;
}
}
return area;
}
private SearchIssuesRequest CreateQuery(RepositoryConfig repoInfo)
{
// Find all open issues
// That are marked as customer reported
SearchIssuesRequest requestOptions = new SearchIssuesRequest()
{
Repos = new RepositoryCollection(),
Labels = new string[] { Constants.Labels.CustomerReported },
State = ItemState.Open
};
requestOptions.Repos.Add(repoInfo.Owner, repoInfo.Repo);
return requestOptions;
}
}
}

Просмотреть файл

@ -0,0 +1,18 @@
using System;
namespace GitHubIssues.Reports
{
internal partial class FindCustomerRelatedIssuesInvalidState
{
[Flags]
private enum ImpactArea
{
None = 0,
Client = 1,
Mgmt = 2,
Service = 4,
EngSys = 8,
MgmtEngSys = 16
}
}
}

Просмотреть файл

@ -0,0 +1,16 @@
using System;
namespace GitHubIssues.Reports
{
internal partial class FindCustomerRelatedIssuesInvalidState
{
[Flags]
private enum IssueType
{
None = 0,
Bug = 1,
Feature = 2,
Question = 4,
}
}
}

Просмотреть файл

@ -0,0 +1,117 @@
using GitHubIssues.Helpers;
using GitHubIssues.Html;
using GitHubIssues.Models;
using Microsoft.Extensions.Logging;
using Octokit;
using System;
using System.Collections.Generic;
using System.Linq;
namespace GitHubIssues.Reports
{
internal class FindIssuesInBacklogMilestones : BaseFunction
{
public FindIssuesInBacklogMilestones(ILogger log) : base(log)
{
}
public override void Execute()
{
foreach (var repositoryConfig in _cmdLine.RepositoriesList)
{
HtmlPageCreator emailBody = new HtmlPageCreator($"Issues in expired milestones for {repositoryConfig.Repo}");
bool hasFoundIssues = GetIssuesInBacklogMilestones(repositoryConfig, emailBody);
if (hasFoundIssues)
{
// send the email
EmailSender.SendEmail(_cmdLine.EmailToken, _cmdLine.FromEmail, emailBody.GetContent(), repositoryConfig.ToEmail, repositoryConfig.CcEmail,
$"Issues in old milestone for {repositoryConfig.Repo}");
}
}
}
private bool GetIssuesInBacklogMilestones(RepositoryConfig repositoryConfig, HtmlPageCreator emailBody)
{
_log.LogInformation($"Retrieving milestone information for repo {repositoryConfig.Repo}");
IEnumerable<Milestone> milestones = _gitHub.ListMilestones(repositoryConfig).GetAwaiter().GetResult();
List<Milestone> backlogMilestones = new List<Milestone>();
foreach (var item in milestones)
{
if (item.DueOn == null)
{
_log.LogWarning($"Milestone {item.Title} has {item.OpenIssues} open issue(s).");
if (item.OpenIssues > 0)
{
backlogMilestones.Add(item);
}
}
}
_log.LogInformation($"Found {backlogMilestones.Count} past due milestones with active issues");
List<ReportIssue> issuesInBacklogMilestones = new List<ReportIssue>();
foreach (var item in backlogMilestones)
{
_log.LogInformation($"Retrieve issues for milestone {item.Title}");
foreach (var issue in _gitHub.SearchForGitHubIssues(CreateQuery(repositoryConfig, item)))
{
issuesInBacklogMilestones.Add(new ReportIssue()
{
Issue = issue,
Milestone = item,
Note = string.Empty
});
}
}
// Split the list into 3:
// > 12months
// > 6months
// 0-6months
var groups = issuesInBacklogMilestones.GroupBy(i =>
i.Issue.CreatedAt > DateTime.Now.AddMonths(-6) ?
"C. Issues created in the last 6 months" :
i.Issue.CreatedAt <= DateTime.Now.AddMonths(-6) && i.Issue.CreatedAt > DateTime.Now.AddMonths(-12) ?
"B. Issues created between 6 and 12 months ago" :
"A. Issues created more than 12 months ago");
foreach (IGrouping<string, ReportIssue> group in groups.OrderBy(g => g.Key))
{
TableCreator tc = new TableCreator(group.Key);
tc.DefineTableColumn("Milestone", TableCreator.Templates.Milestone);
tc.DefineTableColumn("Created", i => i.Issue.CreatedAt.UtcDateTime.ToShortDateString());
tc.DefineTableColumn("Title", TableCreator.Templates.Title);
tc.DefineTableColumn("Labels", TableCreator.Templates.Labels);
tc.DefineTableColumn("Author", TableCreator.Templates.Author);
tc.DefineTableColumn("Assigned", TableCreator.Templates.Assigned);
emailBody.AddContent(tc.GetContent(group));
}
return issuesInBacklogMilestones.Any();
}
private SearchIssuesRequest CreateQuery(RepositoryConfig repoInfo, Milestone milestone)
{
// Find all open issues
// That are marked with 'Client
// In the specified milestone
SearchIssuesRequest requestOptions = new SearchIssuesRequest()
{
Repos = new RepositoryCollection(),
Labels = new string[] { },
State = ItemState.Open,
Milestone = milestone.Title
};
requestOptions.Repos.Add(repoInfo.Owner, repoInfo.Repo);
return requestOptions;
}
}
}

Просмотреть файл

@ -0,0 +1,101 @@
using GitHubIssues.Helpers;
using GitHubIssues.Html;
using GitHubIssues.Models;
using Microsoft.Extensions.Logging;
using Octokit;
using System;
using System.Collections.Generic;
using System.Linq;
namespace GitHubIssues.Reports
{
internal class FindIssuesInPastDueMilestones : BaseFunction
{
public FindIssuesInPastDueMilestones(ILogger log) : base(log)
{
}
public override void Execute()
{
foreach (var repositoryConfig in _cmdLine.RepositoriesList)
{
HtmlPageCreator emailBody = new HtmlPageCreator($"Issues in expired milestones for {repositoryConfig.Repo}");
bool hasFoundIssues = FindIssuesInPastDuesMilestones(repositoryConfig, emailBody);
if (hasFoundIssues)
{
// send the email
EmailSender.SendEmail(_cmdLine.EmailToken, _cmdLine.FromEmail, emailBody.GetContent(), repositoryConfig.ToEmail, repositoryConfig.CcEmail,
$"Issues in old milestone for {repositoryConfig.Repo}");
}
}
}
private bool FindIssuesInPastDuesMilestones(RepositoryConfig repositoryConfig, HtmlPageCreator emailBody)
{
TableCreator tc = new TableCreator("Issues in past-due milestones");
tc.DefineTableColumn("Milestone", TableCreator.Templates.Milestone);
tc.DefineTableColumn("Title", TableCreator.Templates.Title);
tc.DefineTableColumn("Labels", TableCreator.Templates.Labels);
tc.DefineTableColumn("Author", TableCreator.Templates.Author);
tc.DefineTableColumn("Assigned", TableCreator.Templates.Assigned);
_log.LogInformation($"Retrieving milestone information for repo {repositoryConfig.Repo}");
IEnumerable<Milestone> milestones = _gitHub.ListMilestones(repositoryConfig).GetAwaiter().GetResult();
List<Milestone> pastDueMilestones = new List<Milestone>();
foreach (var item in milestones)
{
if (item.DueOn != null && DateTimeOffset.Now > item.DueOn)
{
_log.LogWarning($"Milestone {item.Title} past due ({item.DueOn.Value}) has {item.OpenIssues} open issue(s).");
if (item.OpenIssues > 0)
{
pastDueMilestones.Add(item);
}
}
}
_log.LogInformation($"Found {pastDueMilestones.Count} past due milestones with active issues");
List<ReportIssue> issuesInPastMilestones = new List<ReportIssue>();
foreach (var item in pastDueMilestones)
{
_log.LogInformation($"Retrieve issues for milestone {item.Title}");
foreach (var issue in _gitHub.SearchForGitHubIssues(CreateQuery(repositoryConfig, item)))
{
issuesInPastMilestones.Add(new ReportIssue()
{
Issue = issue,
Milestone = item,
Note = string.Empty
});
}
}
emailBody.AddContent(tc.GetContent(issuesInPastMilestones));
return issuesInPastMilestones.Any();
}
private SearchIssuesRequest CreateQuery(RepositoryConfig repoInfo, Milestone milestone)
{
// Find all open issues
// That are marked with 'Client
// In the specified milestone
SearchIssuesRequest requestOptions = new SearchIssuesRequest()
{
Repos = new RepositoryCollection(),
Labels = new string[] { },
State = ItemState.Open,
Milestone = milestone.Title
};
requestOptions.Repos.Add(repoInfo.Owner, repoInfo.Repo);
return requestOptions;
}
}
}

Просмотреть файл

@ -0,0 +1,122 @@
using Azure.Storage.Blobs;
using GitHubIssues.Helpers;
using GitHubIssues.Html;
using GitHubIssues.Models;
using Microsoft.Extensions.Logging;
using Octokit;
using OutputColorizer;
using System;
using System.Collections.Generic;
using System.Linq;
namespace GitHubIssues.Reports
{
internal class FindNewGitHubIssuesAndPRs : BaseFunction
{
public FindNewGitHubIssuesAndPRs(ILogger log) : base(log)
{
}
#if DEBUG
private static readonly string ContainerName = "lastaccessed-dev";
#else
private static string ContainerName = "lastaccessed";
#endif
public override void Execute()
{
_log.LogInformation($"Started function execution: {DateTime.Now}");
var storageConnString = Environment.GetEnvironmentVariable("AzureWebJobsStorage");
BlobServiceClient bsc = new BlobServiceClient(storageConnString);
BlobContainerClient bcc = bsc.GetBlobContainerClient(ContainerName);
// create the container
bcc.CreateIfNotExists();
_log.LogInformation("Storage account accessed");
DateTime to = DateTime.UtcNow;
DateTime fromTwoDaysBack = DateTime.UtcNow.AddDays(-2);
foreach (var repositoryConfig in _cmdLine.RepositoriesList)
{
// retrieve the last accessed time for this repository
BlobClient bc = bcc.GetBlobClient($"{repositoryConfig.Owner}_{repositoryConfig.Repo}");
DateTime lastDateRun = DateTime.UtcNow.AddDays(-1);
try
{
string content = StreamHelpers.GetContentAsString(bc.Download().Value.Content);
lastDateRun = DateTime.Parse(content);
}
catch
{
}
_log.LogInformation("Last processed date for {0} is {1}", repositoryConfig, lastDateRun);
string owner = repositoryConfig.Owner;
string repo = repositoryConfig.Repo;
_log.LogInformation("Processing repository {0}\\{1}", owner, repo);
HtmlPageCreator emailBody = new HtmlPageCreator($"New items in {repo}");
SearchIssuesRequest requestOptions = new SearchIssuesRequest()
{
#pragma warning disable CS0618 // Type or member is obsolete
Created = DateRange.Between(fromTwoDaysBack, to),
#pragma warning restore CS0618 // Type or member is obsolete
Order = SortDirection.Descending,
Repos = new RepositoryCollection()
};
requestOptions.Repos.Add(owner, repo);
// get the issues
requestOptions.Is = new[] { IssueIsQualifier.Open, IssueIsQualifier.Issue };
RetrieveItemsFromGitHub(requestOptions, lastDateRun, emailBody, "New issues");
// get the PRs
requestOptions.Is = new[] { IssueIsQualifier.Open, IssueIsQualifier.PullRequest };
RetrieveItemsFromGitHub(requestOptions, lastDateRun, emailBody, "New PRs");
emailBody.AddContent($"<p>Last checked range: {lastDateRun} -> {to} </p>");
_log.LogInformation("Sending email...");
// send the email
EmailSender.SendEmail(_cmdLine.EmailToken, _cmdLine.FromEmail, emailBody.GetContent(), repositoryConfig.ToEmail, repositoryConfig.CcEmail, $"New issues in the {repo} repo as of {to.ToShortDateString()}");
_log.LogInformation("Email sent...");
bc.Upload(StreamHelpers.GetStreamForString(to.ToUniversalTime().ToString()), overwrite: true);
_log.LogInformation("Persisted last event time for {0} as {1}", repositoryConfig, to);
}
}
private bool RetrieveItemsFromGitHub(SearchIssuesRequest requestOptions, DateTime from, HtmlPageCreator emailBody, string header)
{
TableCreator tc = new TableCreator(header);
tc.DefineTableColumn("Title", TableCreator.Templates.Title);
tc.DefineTableColumn("Labels", TableCreator.Templates.Labels);
tc.DefineTableColumn("Author", TableCreator.Templates.Author);
tc.DefineTableColumn("Assigned", TableCreator.Templates.Assigned);
Colorizer.WriteLine("Retrieving issues");
List<ReportIssue> issues = new List<ReportIssue>();
foreach (var issue in _gitHub.SearchForGitHubIssues(requestOptions))
{
if (issue.CreatedAt.ToUniversalTime() >= from.ToUniversalTime())
{
issues.Add(new ReportIssue() { Issue = issue, Note = string.Empty, Milestone = null });
}
}
emailBody.AddContent(tc.GetContent(issues));
return issues.Any();
}
}
}

Просмотреть файл

@ -0,0 +1,76 @@
using GitHubIssues.Helpers;
using GitHubIssues.Html;
using GitHubIssues.Models;
using Microsoft.Extensions.Logging;
using Octokit;
using System;
using System.Collections.Generic;
using System.Linq;
namespace GitHubIssues.Reports
{
internal class FindStalePRs : BaseFunction
{
public FindStalePRs(ILogger log) : base(log)
{
}
public override void Execute()
{
foreach (var repositoryConfig in _cmdLine.RepositoriesList)
{
HtmlPageCreator emailBody = new HtmlPageCreator($"Pull Requests older than 3 months in {repositoryConfig.Repo}");
bool hasFoundPRs = FindStalePRsInRepo(repositoryConfig, emailBody);
if (hasFoundPRs)
{
// send the email
EmailSender.SendEmail(_cmdLine.EmailToken, _cmdLine.FromEmail, emailBody.GetContent(), repositoryConfig.ToEmail, repositoryConfig.CcEmail,
$"Pull Requests older than 3 months in {repositoryConfig.Repo}");
}
}
}
private bool FindStalePRsInRepo(RepositoryConfig repositoryConfig, HtmlPageCreator emailBody)
{
TableCreator tc = new TableCreator($"Pull Requests older than {DateTime.Now.AddMonths(-3).ToShortDateString()}");
tc.DefineTableColumn("Title", TableCreator.Templates.Title);
tc.DefineTableColumn("Labels", TableCreator.Templates.Labels);
tc.DefineTableColumn("Author", TableCreator.Templates.Author);
tc.DefineTableColumn("Assigned", TableCreator.Templates.Assigned);
_log.LogInformation($"Retrieving PR information for repo {repositoryConfig.Repo}");
List<ReportIssue> oldPrs = new List<ReportIssue>();
foreach (var issue in _gitHub.SearchForGitHubIssues(CreateQuery(repositoryConfig)))
{
_log.LogInformation($"Found stale PR {issue.Number}");
oldPrs.Add(new ReportIssue() { Issue = issue, Note = string.Empty, Milestone = null });
}
emailBody.AddContent(tc.GetContent(oldPrs));
return oldPrs.Any();
}
private SearchIssuesRequest CreateQuery(RepositoryConfig repoInfo)
{
// Find all open PRs
// That were created more than 3 months ago
SearchIssuesRequest requestOptions = new SearchIssuesRequest()
{
Repos = new RepositoryCollection(),
State = ItemState.Open,
#pragma warning disable CS0618 // Type or member is obsolete
Created = DateRange.LessThanOrEquals(DateTime.Now.AddMonths(-3)),
#pragma warning restore CS0618 // Type or member is obsolete
Order = SortDirection.Ascending,
Is = new[] { IssueIsQualifier.PullRequest }
};
requestOptions.Repos.Add(repoInfo.Owner, repoInfo.Repo);
return requestOptions;
}
}
}

Просмотреть файл

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp2.1</TargetFramework>
<AzureFunctionsVersion>v2</AzureFunctionsVersion>
<!-- Do not warn about using assemblies without a strong name signature-->
<NoWarn>$(NoWarn);8002</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Storage.Blobs" Version="12.0.0" />
<PackageReference Include="CommandLine.Net" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Sdk.Functions" Version="1.0.28" />
<PackageReference Include="Octokit" Version="0.36.0" />
<PackageReference Include="Sendgrid" Version="9.12.0" />
</ItemGroup>
<ItemGroup>
<None Update="host.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="local.settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</None>
</ItemGroup>
<ItemGroup>
<Content Remove="excluded_folder\**" />
</ItemGroup>
</Project>

Просмотреть файл

@ -0,0 +1,3 @@
{
"version": "2.0"
}