232 lines
9.0 KiB
CoffeeScript
232 lines
9.0 KiB
CoffeeScript
flatiron = require 'flatiron'
|
|
connect = require 'connect'
|
|
https = require 'https'
|
|
fs = require "fs"
|
|
yaml = require "js-yaml"
|
|
eco = require 'eco'
|
|
|
|
# Helper object for GitHub Issues.
|
|
Issues =
|
|
|
|
# Make HTTPS GET to GitHub API v3.
|
|
get: (path, type, callback) ->
|
|
options =
|
|
host: "api.github.com"
|
|
method: "GET"
|
|
path: path
|
|
headers:
|
|
'User-Agent': 'Scrum Burndown (1)'
|
|
|
|
if Issues.config.api_token
|
|
options.headers.Authorization = 'token '+Issues.config.api_token
|
|
|
|
https.request(options, (response) ->
|
|
if response.statusCode is 200
|
|
json = ""
|
|
response.on "data", (chunk) -> json += chunk
|
|
|
|
response.on "end", -> callback JSON.parse(json), type
|
|
else
|
|
throw response.statusCode
|
|
).end()
|
|
|
|
# URLs to API.
|
|
getOpenIssues: (callback) -> Issues.get "/repos/#{Issues.config.github_user}/#{Issues.config.github_project}/issues?state=open", 'issues', callback
|
|
getClosedIssues: (callback) -> Issues.get "/repos/#{Issues.config.github_user}/#{Issues.config.github_project}/issues?state=closed", 'issues', callback
|
|
getMilestones: (callback) -> Issues.get "/repos/#{Issues.config.github_user}/#{Issues.config.github_project}/milestones", 'milestones', callback
|
|
|
|
# Convert GitHub ISO 8601 to JS timestamp at the beginning of UTC day!'
|
|
dateToTime: (date) ->
|
|
# Some milestones do not have due dates.
|
|
return 0 unless date?
|
|
# Add miliseconds and create `Date`.
|
|
date = new Date(date[0...date.length - 1] + '.000' + date.charAt date.length-1)
|
|
# Move to the beginning of the day (at 9am BST, 8am GMT, so we do not worry about time shifts).
|
|
date = new Date date.getFullYear(), date.getMonth(), date.getDate(), 9
|
|
# Return timestamp.
|
|
date.getTime()
|
|
|
|
# Format issues for display in a listing.
|
|
format: (issue) ->
|
|
# Format the timestamps.
|
|
if issue.created_at? then issue.created_at = new Date(Issues.dateToTime(issue.created_at)).toUTCString()
|
|
if issue.updated_at? then issue.updated_at = new Date(Issues.dateToTime(issue.updated_at)).toUTCString()
|
|
|
|
issue
|
|
|
|
# Config filters.
|
|
app = flatiron.app
|
|
app.use flatiron.plugins.http,
|
|
'before': [
|
|
connect.favicon()
|
|
connect.static __dirname + '/public'
|
|
]
|
|
|
|
# Eco templating.
|
|
app.use
|
|
name: "eco-templating"
|
|
attach: (options) ->
|
|
app.eco = (path, data, cb) ->
|
|
fs.readFile "./templates/#{path}.eco", "utf8", (err, template) ->
|
|
if err then cb err, null
|
|
else
|
|
try
|
|
cb null, eco.render template, data
|
|
catch e
|
|
cb e, null
|
|
|
|
# Show burndown chart.
|
|
getBurndown = ->
|
|
console.log 'Get burndown chart'
|
|
|
|
resources = 3 ; store = { 'issues': [], 'milestones': [] }
|
|
done = (data, type) =>
|
|
# One less to do.
|
|
resources--
|
|
|
|
switch type
|
|
when 'issues' then store.issues = store.issues.concat data
|
|
when 'milestones' then store.milestones = store.milestones.concat data
|
|
|
|
# Are we done?
|
|
if resources is 0
|
|
# Do we actually have an open milestone?
|
|
if store.milestones.length > 0
|
|
# Store the current milestone and its size.
|
|
current = { 'milestone': {}, 'diff': +Infinity, 'size': 0 }
|
|
|
|
# Determine the 'current' milestone
|
|
now = new Date().getTime()
|
|
for milestone in store.milestones
|
|
# JS expects more accuracy.
|
|
due = Issues.dateToTime milestone['due_on']
|
|
# Is this the 'current' one?
|
|
diff = due - now
|
|
if diff > 0 and diff < current.diff
|
|
current.milestone = milestone ; current.diff = diff ; current.due = due
|
|
|
|
# Create n dict with all dates in the milestone span.
|
|
days = {} ; totalDays = 0 ; totalNonWorkingDays = 0
|
|
day = Issues.dateToTime current.milestone.created_at
|
|
while day < current.due
|
|
# Do we have weekends configured?
|
|
if Issues.config.weekend? and Issues.config.weekend instanceof Array
|
|
dayOfWeek = new Date(day).getDay()
|
|
# Fix stupid Abrahamic tradition.
|
|
if dayOfWeek is 0 then dayOfWeek = 7
|
|
|
|
# Does this day fall on a weekend?
|
|
if dayOfWeek in Issues.config.weekend
|
|
totalNonWorkingDays += 1
|
|
# Save the day.
|
|
days[day] = { 'issues': [], 'actual': 0, 'ideal': 0, 'weekend': true }
|
|
else
|
|
# Save the day.
|
|
days[day] = { 'issues': [], 'actual': 0, 'ideal': 0, 'weekend': false }
|
|
else
|
|
# Save the day.
|
|
days[day] = { 'issues': [], 'actual': 0, 'ideal': 0, 'weekend': false }
|
|
|
|
# Shift by a day.
|
|
day += 1000 * 60 * 60 * 24
|
|
# Increase the total count.
|
|
totalDays += 1
|
|
|
|
# Now go through the issues and place them to the appropriate days.
|
|
for issue in store.issues
|
|
# This milestone?
|
|
if issue.milestone?.number is current.milestone.number
|
|
# Has a size label?
|
|
if issue.labels?
|
|
issue.size = do (issue) ->
|
|
for label in issue.labels
|
|
if label.name.indexOf("size ") is 0
|
|
return parseInt label.name[5...]
|
|
|
|
if issue.size?
|
|
# Increase the total size of the milestone.
|
|
current.size += issue.size
|
|
# Is it closed?
|
|
if issue.closed_at?
|
|
# Save it.
|
|
days[Issues.dateToTime issue.closed_at]['issues'].push issue
|
|
|
|
# Calculate the predicted daily velocity.
|
|
dailyIdeal = current['size'] / (totalDays - totalNonWorkingDays) ; ideal = current['size']
|
|
|
|
# Go through the days and save the number of outstanding issues size.
|
|
for day, d of days
|
|
# Does this day have any closed issues? Reduce the total for this milestone.
|
|
for issue in d['issues']
|
|
current['size'] -= issue.size
|
|
# Save the oustanding count for that day.
|
|
days[day].actual = current['size']
|
|
|
|
# Save the predicted velocity for that day if it is not a non-working day.
|
|
ideal -= dailyIdeal unless days[day].weekend
|
|
days[day].ideal = ideal
|
|
|
|
# Finally send to client.
|
|
app.eco 'burndown',
|
|
'days': days
|
|
'project': Issues.config.project_name
|
|
'base_url': Issues.config.base_url
|
|
, (err, html) =>
|
|
throw err if err
|
|
@res.writeHead 200, "content-type": "text/html"
|
|
@res.write html
|
|
@res.end()
|
|
|
|
else
|
|
# No current milestone.
|
|
app.eco 'empty',
|
|
'project': Issues.config.project_name
|
|
'base_url': Issues.config.base_url
|
|
, (err, html) =>
|
|
throw err if err
|
|
@res.writeHead 200, "content-type": "text/html"
|
|
@res.write html
|
|
@res.end()
|
|
|
|
# Get Milestones, Opened and Closed Tickets.
|
|
Issues.getMilestones done
|
|
Issues.getOpenIssues done
|
|
Issues.getClosedIssues done
|
|
|
|
# Show open issues.
|
|
getIssues = ->
|
|
console.log 'Get open issues'
|
|
|
|
Issues.getOpenIssues (issues) =>
|
|
|
|
# Replace the dates in issues with nice dates.
|
|
issues = ( Issues.format(issue) for issue in issues )
|
|
|
|
app.eco 'issues',
|
|
'issues': issues
|
|
'project': Issues.config.project_name
|
|
'base_url': Issues.config.base_url
|
|
, (err, html) =>
|
|
throw err if err
|
|
@res.writeHead 200, "content-type": "text/html"
|
|
@res.write html
|
|
@res.end()
|
|
|
|
# Routes
|
|
app.router.path '/', ->
|
|
@get getBurndown
|
|
|
|
app.router.path '/burndown', ->
|
|
@get getBurndown
|
|
|
|
app.router.path '/issues', ->
|
|
@get getIssues
|
|
|
|
# Fetch config and start server.
|
|
fs.readFile "config.yml", "utf8", (err, data) ->
|
|
Issues.config = yaml.load data
|
|
|
|
app.start process.env.PORT, (err) ->
|
|
throw err if err
|
|
console.log "Listening on port #{app.server.address().port}"
|