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:
Родитель
624e8fc1b6
Коммит
5333061f0e
|
@ -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"
|
||||
}
|
Загрузка…
Ссылка в новой задаче