This notebook produces a project report based on your GitHub repository and attempts to create a burndown and completion estimate for a specified milestone.
Generate a user token to get rid of public API throttling policies for anonymous users
flowchart
parameters[Gather org and repo ids] --> creategithubclient[create github client]
creategithubclient[create github client] --> collect[(collect milestones)]
collect[(collect milestones)] --> collectissues[(collect milestone issues)]
collectissues[(collect milestone issues)] --> process[process milestone issues]
process[process milestone issues] --> derive[calculate burndown]
derive[calculate burndown] --> output[display burndown and milestone work by tag]
#!set --name organization --value @input:"Enter the name for your GitHub organization"
#!set --name repositoryName --value @input:repositoryName
#!set --name token --value @password:github-api-token
Importing packages and setting up connection
#r "nuget: Octokit, 4.0.0"
using Octokit;
using Microsoft.DotNet.Interactive.Formatting;
using Microsoft.DotNet.Interactive.Formatting.TabularData;
using Microsoft.DotNet.Interactive;
using Microsoft.DotNet.Interactive.Commands;
using System.Collections.Generic;
plotlyloader = (require.config({
paths: {
d3: 'https://cdn.jsdelivr.net/npm/d3@7.4.4/dist/d3.min',
jquery: 'https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min',
plotly: 'https://cdn.plot.ly/plotly-2.14.0.min'
},
shim: {
plotly: {
deps: ['d3', 'jquery'],
exports: 'plotly'
}
}
}) || require);
var options = new ApiOptions();
var gitHubClient = new GitHubClient(new ProductHeaderValue("notebook"));
if (!string.IsNullOrEmpty(token)) {
Console.WriteLine("Using GitHub API token");
var tokenAuth = new Credentials(token);
gitHubClient.Credentials = tokenAuth;
} else {
Console.WriteLine("Using anonymous GitHub API");
}
Using GitHub API token
var milestones = (await gitHubClient.Issue.Milestone.GetAllForRepository(organization, repositoryName, options)).Select(m => new{
Milestone = m,
Issues = (gitHubClient.Issue.GetAllForRepository(organization, repositoryName, new RepositoryIssueRequest {
Milestone= m.Number.ToString(),
State = ItemStateFilter.All
}, options)).Result.ToArray()
}).ToArray();
var milestoneData = milestones.Select(m =>{
var lastCountOpen = -1;
var startDate = m.Milestone.CreatedAt.DateTime;
var endDate = DateTime.Now.Date;
var ClosedEveryDay = m.Issues.Where(i => i.ClosedAt.HasValue).GroupBy(i => i.ClosedAt.Value.Date).Select(g => new {Date = g.Key, Count = g.Count()}).OrderBy(e => e.Date).ToArray();
//var OpenedEveryDay = m.Issues.GroupBy(i => i.CreatedAt.Date).Select(g => new {Date = g.Key, Count = g.Count()}).OrderBy(e => e.Date).ToArray();
//var dates = ClosedEveryDay.Select(e => e.Date).Union(OpenedEveryDay.Select(e => e.Date)).Distinct().OrderBy(d => d).ToArray();
var RollingClosedIssues = ClosedEveryDay.Select(e => new {e.Date, Count = ClosedEveryDay.Where(d => d.Date <= e.Date).Select(d => d.Count).Sum()}).ToArray();
var RollingOpenIssues = Enumerable.Range(0, (int)(endDate - startDate).TotalDays).Select( i => {
var date = startDate.AddDays(i);
var openedCount = m.Issues.Where(i => (i.ClosedAt.HasValue == false) || (i.ClosedAt > date)).Count();
return new {Date = date, Count = openedCount};
}).Where(e => {
if(e.Count == lastCountOpen){
return false;
}else{
lastCountOpen = e.Count;
return true;
}
}).ToArray();
// OpenedEveryDay.Select(e => new {e.Date, Count = OpenedEveryDay.Where(d => d.Date <= e.Date).Select(d => d.Count).Sum() - ClosedEveryDay.Where(d => d.Date <= e.Date).Select(d => d.Count).Sum()}).ToArray();
var isClosed = m.Milestone.State.ToString().ToLowerInvariant() == "closed";
var extrapolations = new List<(DateTime Date, int Count)>();
var AtRisk = false;
if(!isClosed){
var closingIssueSpeed = 0.0;
var alpha = 0.40;
for(var i = 0; i < RollingClosedIssues.Length - 1; i++){
var current = RollingClosedIssues[i];
var next = RollingClosedIssues[i + 1];
var days = (next.Date - current.Date).TotalDays;
if(days > 0){
var currentSpeed = (double)(next.Count - current.Count) / days;
closingIssueSpeed = ((1.0-alpha)*currentSpeed) + (alpha*closingIssueSpeed) ;
}
}
closingIssueSpeed = Math.Round(closingIssueSpeed,4, MidpointRounding.AwayFromZero);
var lastSample = RollingOpenIssues.Last();
Console.WriteLine($"Milestone {m.Milestone.Title} is {m.Milestone.State}. Closing speed is {closingIssueSpeed} issues per day at {lastSample.Date}.");
extrapolations = new List<(DateTime Date, int Count)>{
(lastSample.Date, lastSample.Count)
};
// take into account any pause to today
for(var i = 0; i < (int)((endDate - lastSample.Date).TotalDays); i++){
var nextCount = lastSample.Count;
extrapolations.Add((lastSample.Date.AddDays(i), nextCount));
}
for(var i = 0; i < extrapolations.Count - 1; i++){
var current = extrapolations[i];
var next = extrapolations[i + 1];
var days = (next.Date - current.Date).TotalDays;
if(days > 0){
closingIssueSpeed = alpha*closingIssueSpeed ;
}
}
closingIssueSpeed = Math.Round(closingIssueSpeed,2, MidpointRounding.AwayFromZero);
Console.WriteLine($"Milestone {m.Milestone.Title} is {m.Milestone.State} and has {extrapolations.Count} extrapolated points. Closing speed is {closingIssueSpeed} issues per day.");
var lastExtrapolatedSample = extrapolations.Last();
var nextSample = lastExtrapolatedSample.Date.AddDays(1);
var closeDate = lastExtrapolatedSample.Date.AddMonths(1);
if(closingIssueSpeed > 0){
var daysToClose = lastExtrapolatedSample.Count / closingIssueSpeed;
closeDate = lastExtrapolatedSample.Date.AddDays(daysToClose);
Console.WriteLine($"Milestone {m.Milestone.Title} is {m.Milestone.State} will be closed by {closeDate}.");
}else{
AtRisk = true;
Console.WriteLine($"Milestone {m.Milestone.Title} will not be closed anytime soon.");
}
var lastCount = lastExtrapolatedSample.Count;
while(nextSample < closeDate){
lastCount -= (int)(closingIssueSpeed);
extrapolations.Add((nextSample, lastCount));
nextSample = nextSample.AddDays(1);
}
}
return new {
m.Milestone,
m.Issues,
ClosedEveryDay,
// OpenedEveryDay,
RollingClosedIssues,
RollingOpenIssues,
AtRisk,
ToComplete = extrapolations.Select(e => new {e.Date, e.Count}).ToArray()
};
}).ToArray();
Milestone VS Code Extension GA is open. Closing speed is 0.3688 issues per day at 06/02/2023 21:40:38. Milestone VS Code Extension GA is open and has 10 extrapolated points. Closing speed is 0 issues per day. Milestone VS Code Extension GA will not be closed anytime soon. Milestone API Stabilization is open. Closing speed is 0 issues per day at 03/08/2022 15:55:09. Milestone API Stabilization is open and has 197 extrapolated points. Closing speed is 0 issues per day. Milestone API Stabilization will not be closed anytime soon.
milestoneData.Select(m => new {m.Milestone.Title, m.Milestone.Description, m.Milestone.DueOn, m.Milestone.ClosedAt, m.Milestone.State,m.Milestone.OpenIssues, m.Milestone.ClosedIssues, m.Milestone.CreatedAt, m.AtRisk}).ToTabularDataResource().Display();
Title | Description | DueOn | ClosedAt | State | OpenIssues | ClosedIssues | CreatedAt | AtRisk | ||||
VS Code Extension GA | <null> | <null> |
| 8 | 50 | 2021-10-05 21:40:38Z | True | |||||
API Stabilization | <null> | <null> |
| 11 | 0 | 2022-08-03 15:55:09Z | True |
var milestoneBurndown = milestoneData.Where(m => m.Milestone.State == "Open")
.OrderByDescending(m => m.Milestone.CreatedAt)
.Select(m => new { Title = m.Milestone.Title, OpenIssues = m.RollingOpenIssues.ToArray(), ToComplete = m.ToComplete.ToArray(), m.AtRisk}).ToArray();
#!share --from csharp milestoneBurndown
<div id="target"></div>
const traces = [];
const layout = {
title: 'Milestone Burndown',
grid: { rows: milestoneBurndown.length, columns: 1, pattern: 'independent' },
annotations: []
};
for(let i = 0; i < milestoneBurndown.length; i++) {
layout[`xaxis${i+1}}`] = {};
layout[`yaxis${i+1}`] = { title: "Open items" };
const milestone = milestoneBurndown[i];
const done = {
y: Array.from(milestone.OpenIssues.map(x => x.Count)),
x: Array.from(milestone.OpenIssues.map(x => x.Date)),
mode: 'lines',
//name: `Done [${milestone.Title}]`,
line: {
dash: 'solid',
width: 4
},
xaxis: `x${i+1}`,
yaxis: `y${i+1}`,
//hovertemplate: `<b>${milestone.Title}</b><br><i>Issue count</i>: %{y}<br><b>Date</b>: %{x}<extra></extra>`,
type: 'scattergl'
};
const toDo = {
y: Array.from(milestone.ToComplete.map(x => x.Count)),
x: Array.from(milestone.ToComplete.map(x => x.Date)),
mode: 'lines',
//name: `To Do [${milestone.Title}]`,
line: {
dash: 'dashdot',
width: 4
},
xaxis: `x${i+1}`,
yaxis: `y${i+1}`,
//hovertemplate: `<b>${milestone.Title} Projection</b>}<br><i>Issue count</i>: %{y}<br><b>Date</b>: %{x}<extra></extra>`,
type: 'scattergl'
};
layout.annotations.push({
x: done.x[0],
y: done.y.reduce((max, value) => {return Math.max(max, value)}),
yshift: 10 + done.line.width,
xanchor: 'left',
xref: `x${i+1}`,
yref: `y${i+1}`,
text: milestone.Title,
showarrow: false
});
if(milestone.AtRisk) {
layout.annotations.push({
x: done.x[done.x.length - 1],
y: done.y[done.y.length - 1],
xanchor: 'center',
yanchor: 'bottom',
align: 'center',
xref: `x${i+1}`,
yref: `y${i+1}`,
text: "\u26A0",
showarrow: true,
ax: 0,
ay: -(20 + done.line.width),
font: {
color: "red",
size: 30
}
});
}
traces.push(done);
traces.push(toDo);
}
exportData = { traces, layout };
plotlyloader(['d3', 'plotly'], function (d3, plotly) {
console.log("Plotly loaded");
plotly.newPlot('target', exportData.traces, exportData.layout, {responsive: true});
});
using Microsoft.DotNet.Interactive;
using Microsoft.DotNet.Interactive.Commands;
async Task PieWithMermaid(IEnumerable<(string area,int doneCount)> data, string label){
double total = data.Select(d => d.doneCount).Sum();
var slices = data.Select(d => $" \"{d.area}\" : {Math.Round((d.doneCount/total)*100.0, 2)}").ToArray();
var mermaidPieMarkdown = new StringBuilder();
mermaidPieMarkdown.AppendLine("pie");
mermaidPieMarkdown.AppendLine($@" title {label}");
foreach(var slice in slices){
mermaidPieMarkdown.AppendLine(slice);
}
await Kernel.Root.SendAsync(new SendEditableCode("mermaid", mermaidPieMarkdown.ToString()));
await Task.Delay(500);
}
foreach(var milestone in milestoneData.OrderBy(m => m.Milestone.CreatedAt)){
var doneIssues = milestone.Issues.Where(i => i.ClosedAt.HasValue).ToArray();
if(doneIssues.Length > 0) {
var doneData = doneIssues.SelectMany( i => i.Labels.Select(l => l.Name)).Where(l => l.StartsWith("Area-")).GroupBy(l => l).Select(l => (l.Key,l.Count()));
await PieWithMermaid(doneData, $"Milestone: {milestone.Milestone.Title} work done by tag ({doneIssues.Length} of { milestone.Issues.Count()} items)");
}
var toDoIssues = milestone.Issues.Where(i => i.ClosedAt.HasValue == false).ToArray();
if(toDoIssues.Length > 0) {
var toDoData = toDoIssues.SelectMany( i => i.Labels.Select(l => l.Name)).Where(l => l.StartsWith("Area-")).GroupBy(l => l).Select(l => (l.Key,l.Count()));
await PieWithMermaid(toDoData, $"Milestone: {milestone.Milestone.Title} work to do by tag({toDoIssues.Length} of { milestone.Issues.Count()} items)");
}
}
pie
title Milestone: VS Code Extension GA work done by tag (50 of 58 items)
"Area-VS Code Extension" : 51.92
"Area-Language Services" : 3.85
"Area-Variable sharing" : 11.54
"Area-F#" : 1.92
"Area-C#" : 1.92
"Area-Accessibility" : 3.85
"Area-Messaging / scheduling / comms" : 5.77
"Area-Build & Infrastructure" : 1.92
"Area-Formatting" : 3.85
"Area-Packages and Extensions" : 1.92
"Area-Telemetry" : 3.85
"Area-VS Code Jupyter Extension Interop" : 1.92
"Area-Performance" : 1.92
"Area-Getting Started" : 1.92
"Area-JavaScript HTML CSS" : 1.92