Merge pull request #20 from radekstepan/rework

Rework made the new new
This commit is contained in:
Radek Stepan 2013-09-29 16:04:07 -07:00
commit 1d803a9bf4
51 changed files with 30662 additions and 1074 deletions

6
.gitignore vendored
View File

@ -1 +1,5 @@
node_modules/
node_modules/
.idea/
*.log
src/components/
config.json

4
Makefile Normal file
View File

@ -0,0 +1,4 @@
test:
./node_modules/.bin/mocha --compilers coffee:coffee-script --reporter spec --ui exports --bail
.PHONY: test

164
README.md
View File

@ -1,78 +1,118 @@
# GitHub Burndown App
#GitHub Burndown Chart
An app that displays a burndown chart for your GitHub Issues.
**The original app got completely rewritten, see [notes](#rewrite), thank you.**
Displays a burndown chart from a set of GitHub issues in the current milestone.
[ ![Codeship Status for radekstepan/github-burndown-chart](https://www.codeship.io/projects/d69f4420-e5b0-0130-bbae-1632ddfb80f8/status?branch=rework)](https://www.codeship.io/projects/5855)
##Features
1. Client side; easily hosted on GitHub Pages.
1. Private repos; use your GitHub API Token hiding it from public view if need be.
1. Off days; specify which days of the week to leave out from ideal burndown progression line.
1. Trend line; to see if you can make it to the deadline at this pace.
![image](https://raw.github.com/radekstepan/github-burndown-chart/original/example.png)
## Requirements:
##Quickstart
You can install all the following dependencies by running:
1. Choose a **repo** that you want to display burndown chart for.
1. Make sure this repo has some **issues** assigned to a **milestone**.
1. Put some **labels** on the issues looking like this: `size 1`, `size 3` etc.
1. **Close** some of them labeled issues.
1. Visit [http://radekstepan.com/github-burndown-chart/](http://radekstepan.com/github-burndown-chart/) following the instructions there.
##Configuration
There are three modes of operation balancing between usability & security:
1. **Static Mode**: you can just serve the `public` directory using a static file server or GitHub Pages. No config needed, just serve the app and point to your repo in the browser, e.g.: `http://127.0.0.1:8000/#!/radekstepan/disposable`. You are rate limited to the tune of [60 requests per hour](http://developer.github.com/v3/#rate-limiting).
1. **Static Mode (Public Token)**: as before but now you want to use your [GitHub OAuth2 API Token](http://developer.github.com/v3/#authentication) in the config. This will require you to specify the token in the `config.json` file as outlined below.
1. **Proxy Mode (Private Token)**: you find it preposterous to share your token with the world. In this case you will need to serve the app using the [Proxy Mode](#proxy-mode). Your token will be scrubbed from the config file and all requests be routed through a proxy.
All of the following fields are defined in `config.json` and none of them, including the file itself, are required:
###Size Label
The way we are getting a size of an issue from GitHub is by putting a label on it. The following regex (string) specifies which part of the label represents the number.
```json
{
"size_label": "^size (\\d+)$"
}
```
This is also the default label if no other is specified.
###Token
Your OAuth2 token from GitHub. Get it [here](https://github.com/settings/applications). Bear in mind that if you just statically serve the app, everybody will be able to see the token in transmission. If you would like to avoid that, use the [Proxy Mode](#proxy-mode).
Using the token increases your limit of requests per hour from [60 to 5000](http://developer.github.com/v3/#rate-limiting).
```json
{
"token": "API_TOKEN"
}
```
###Off Days/Weekends
An array of day integers (Monday = 1) representing days of the week when you are not working. This will make the expected burndown line be more accurate.
```json
{
"off_days": [ 6, 7 ]
}
```
##Proxy Mode
Use this strategy if you do not wish for your token to be publicly visible. Proxy mode routes all requests from the client side app through it, scrubbing the token from the `config.json` file. It is *slightly* slower than requesting data straight from GitHub of course.
Make sure you have [CoffeeScript](http://coffeescript.org/) installed:
```bash
$ npm install coffeescript -g
```
Then start the proxy passing port number as an argument:
```bash
$ PORT=1234 coffee proxy.coffee
```
Visit the port in question in the browser and continue as before.
##Build It
If you would like to run your own build for a custom version of the app, use the [Apps/B Builder](https://github.com/intermine/apps-b-builder).
In short:
```bash
$ npm install apps-b-builder -g
$ apps-b build ./src/ ./build/
```
##Test It
```bash
$ npm install -d
$ make test
```
- [CoffeeScript](http://coffeescript.org/)
- [express](http://expressjs.com/)
- [eco](https://github.com/sstephenson/eco)
- [js-yaml](https://github.com/visionmedia/js-yaml)
Each bugfix receives an accompanying test case.
## Configure:
##Rewrite
The app is configured by pointing to a GitHub user/project. Do so in `config.yml`:
The original app got rewritten from a clunky server side to a (better) client side app. Some tests are also provided and more will be going into the future.
```yaml
github_user: 'intermine'
github_project: 'InterMine'
project_name: 'Core InterMine Project'
```
If you are upgrading from the previous app, then please bear in mind that `config.yaml` is replaced with `config.json`.
The `project_name` key-value pair represents the title of the burndown chart that you will see in the top right corner of the page.
If you would like to use the original app, please refer to the `original` branch.
To configure the app for a private GitHub project, you must additionally set the `api_token` key-value pair in `config.yml`:
##Thanks
```yaml
api_token: '5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8'
```
To generate an access token, see your [GitHub Application Settings](https://github.com/settings/applications).
### Milestones
Then visit your GitHub project's Issues page and create a new milestone with a date due in the future. This will represent your iteration. This app will pick the Milestone with the **closest due date in the future** as the *current* one.
### Sizes
Then assign a few labels to tickets in this Milestone. These labels will represent your perceived size of the task. The label takes a form of *size [number]* so to say that an Issue is as big as *5* points I would create and assign this label (don't worry about the colors...):
```
size 5
```
### Weekends
If you have days when you do not work on a project, edit the `config.yml` file with a list of days of the week when you are off. The numbers are 1 indexed and follow the international standard of starting a week on Monday, so for a Saturday and Sunday weekend do this:
```yaml
weekend: [ 6, 7 ]
```
### Base URL to app
If the app does not live in the root path of your server, edit the `base_url` property in the config file.
## Use:
```bash
$ node start.js
```
Then visit [http://127.0.0.1:3000/](http://127.0.0.1:3000/) or whichever port was configured in `process.env.PORT`.
The **orange line** - this represents you closing the Issues as you go through them. When you hover over it you will see, for each day, what the closed Issues were and how many points are left.
The **blue line** - this represents the dropping size of the outstanding Issues planned for the iteration/Milestone.
There is nothing to save in a database so each refresh of the page fetches all of the latest information from GitHub.
Enjoy!
Thank you for using the app and your feedback/comments are very much welcome. Radek

View File

@ -1,231 +0,0 @@
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}"

684
build/build.css Normal file
View File

@ -0,0 +1,684 @@
/*! normalize.css v2.1.2 | MIT License | git.io/normalize */
/* ==========================================================================
HTML5 display definitions
========================================================================== */
/**
* Correct `block` display not defined in IE 8/9.
*/
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
main,
nav,
section,
summary {
display: block;
}
/**
* Correct `inline-block` display not defined in IE 8/9.
*/
audio,
canvas,
video {
display: inline-block;
}
/**
* Prevent modern browsers from displaying `audio` without controls.
* Remove excess height in iOS 5 devices.
*/
audio:not([controls]) {
display: none;
height: 0;
}
/**
* Address `[hidden]` styling not present in IE 8/9.
* Hide the `template` element in IE, Safari, and Firefox < 22.
*/
[hidden],
template {
display: none;
}
/* ==========================================================================
Base
========================================================================== */
/**
* 1. Set default font family to sans-serif.
* 2. Prevent iOS text size adjust after orientation change, without disabling
* user zoom.
*/
html {
font-family: sans-serif; /* 1 */
-ms-text-size-adjust: 100%; /* 2 */
-webkit-text-size-adjust: 100%; /* 2 */
}
/**
* Remove default margin.
*/
body {
margin: 0;
}
/* ==========================================================================
Links
========================================================================== */
/**
* Remove the gray background color from active links in IE 10.
*/
a {
background: transparent;
}
/**
* Address `outline` inconsistency between Chrome and other browsers.
*/
a:focus {
outline: thin dotted;
}
/**
* Improve readability when focused and also mouse hovered in all browsers.
*/
a:active,
a:hover {
outline: 0;
}
/* ==========================================================================
Typography
========================================================================== */
/**
* Address variable `h1` font-size and margin within `section` and `article`
* contexts in Firefox 4+, Safari 5, and Chrome.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/**
* Address styling not present in IE 8/9, Safari 5, and Chrome.
*/
abbr[title] {
border-bottom: 1px dotted;
}
/**
* Address style set to `bolder` in Firefox 4+, Safari 5, and Chrome.
*/
b,
strong {
font-weight: bold;
}
/**
* Address styling not present in Safari 5 and Chrome.
*/
dfn {
font-style: italic;
}
/**
* Address differences between Firefox and other browsers.
*/
hr {
-moz-box-sizing: content-box;
box-sizing: content-box;
height: 0;
}
/**
* Address styling not present in IE 8/9.
*/
mark {
background: #ff0;
color: #000;
}
/**
* Correct font family set oddly in Safari 5 and Chrome.
*/
code,
kbd,
pre,
samp {
font-family: monospace, serif;
font-size: 1em;
}
/**
* Improve readability of pre-formatted text in all browsers.
*/
pre {
white-space: pre-wrap;
}
/**
* Set consistent quote types.
*/
q {
quotes: "\201C" "\201D" "\2018" "\2019";
}
/**
* Address inconsistent and variable font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` affecting `line-height` in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sup {
top: -0.5em;
}
sub {
bottom: -0.25em;
}
/* ==========================================================================
Embedded content
========================================================================== */
/**
* Remove border when inside `a` element in IE 8/9.
*/
img {
border: 0;
}
/**
* Correct overflow displayed oddly in IE 9.
*/
svg:not(:root) {
overflow: hidden;
}
/* ==========================================================================
Figures
========================================================================== */
/**
* Address margin not present in IE 8/9 and Safari 5.
*/
figure {
margin: 0;
}
/* ==========================================================================
Forms
========================================================================== */
/**
* Define consistent border, margin, and padding.
*/
fieldset {
border: 1px solid #c0c0c0;
margin: 0 2px;
padding: 0.35em 0.625em 0.75em;
}
/**
* 1. Correct `color` not being inherited in IE 8/9.
* 2. Remove padding so people aren't caught out if they zero out fieldsets.
*/
legend {
border: 0; /* 1 */
padding: 0; /* 2 */
}
/**
* 1. Correct font family not being inherited in all browsers.
* 2. Correct font size not being inherited in all browsers.
* 3. Address margins set differently in Firefox 4+, Safari 5, and Chrome.
*/
button,
input,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 2 */
margin: 0; /* 3 */
}
/**
* Address Firefox 4+ setting `line-height` on `input` using `!important` in
* the UA stylesheet.
*/
button,
input {
line-height: normal;
}
/**
* Address inconsistent `text-transform` inheritance for `button` and `select`.
* All other form control elements do not inherit `text-transform` values.
* Correct `button` style inheritance in Chrome, Safari 5+, and IE 8+.
* Correct `select` style inheritance in Firefox 4+ and Opera.
*/
button,
select {
text-transform: none;
}
/**
* 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
* and `video` controls.
* 2. Correct inability to style clickable `input` types in iOS.
* 3. Improve usability and consistency of cursor style between image-type
* `input` and others.
*/
button,
html input[type="button"], /* 1 */
input[type="reset"],
input[type="submit"] {
-webkit-appearance: button; /* 2 */
cursor: pointer; /* 3 */
}
/**
* Re-set default cursor for disabled elements.
*/
button[disabled],
html input[disabled] {
cursor: default;
}
/**
* 1. Address box sizing set to `content-box` in IE 8/9.
* 2. Remove excess padding in IE 8/9.
*/
input[type="checkbox"],
input[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* 1. Address `appearance` set to `searchfield` in Safari 5 and Chrome.
* 2. Address `box-sizing` set to `border-box` in Safari 5 and Chrome
* (include `-moz` to future-proof).
*/
input[type="search"] {
-webkit-appearance: textfield; /* 1 */
-moz-box-sizing: content-box;
-webkit-box-sizing: content-box; /* 2 */
box-sizing: content-box;
}
/**
* Remove inner padding and search cancel button in Safari 5 and Chrome
* on OS X.
*/
input[type="search"]::-webkit-search-cancel-button,
input[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* Remove inner padding and border in Firefox 4+.
*/
button::-moz-focus-inner,
input::-moz-focus-inner {
border: 0;
padding: 0;
}
/**
* 1. Remove default vertical scrollbar in IE 8/9.
* 2. Improve readability and alignment in all browsers.
*/
textarea {
overflow: auto; /* 1 */
vertical-align: top; /* 2 */
}
/* ==========================================================================
Tables
========================================================================== */
/**
* Remove most spacing between table cells.
*/
table {
border-collapse: collapse;
border-spacing: 0;
}
.tip {
position: absolute;
padding: 5px;
z-index: 1000;
/* default offset for edge-cases: https://github.com/component/tip/pull/12 */
top: 0;
left: 0;
}
/* effects */
.tip.fade {
transition: opacity 100ms;
-moz-transition: opacity 100ms;
-webkit-transition: opacity 100ms;
}
.tip-hide {
opacity: 0;
}
.tip {
font-size: 11px;
}
.tip-inner {
background-color: rgba(0,0,0,.75);
color: #fff;
padding: 8px 10px 7px 10px;
text-align: center;
}
.tip-inner {
border-radius: 3px;
-moz-border-radius: 3px;
-webkit-border-radius: 3px;
}
.tip-arrow {
position: absolute;
width: 0;
height: 0;
line-height: 0;
border: 5px dashed rgba(0,0,0,.75);
}
.tip-arrow-north { border-bottom-color: rgba(0,0,0,.75) }
.tip-arrow-south { border-top-color: rgba(0,0,0,.75) }
.tip-arrow-east { border-left-color: rgba(0,0,0,.75) }
.tip-arrow-west { border-right-color: rgba(0,0,0,.75) }
.tip-north .tip-arrow,
.tip-north-east .tip-arrow,
.tip-north-west .tip-arrow {
top: 0px;
left: 50%;
margin-left: -5px;
border-bottom-style: solid;
border-top: none;
border-left-color: transparent;
border-right-color: transparent
}
.tip-south .tip-arrow,
.tip-south-east .tip-arrow,
.tip-south-west .tip-arrow {
bottom: 0;
left: 50%;
margin-left: -5px;
border-top-style: solid;
border-bottom: none;
border-left-color: transparent;
border-right-color: transparent
}
.tip-east .tip-arrow {
right: 0;
top: 50%;
margin-top: -5px;
border-left-style: solid;
border-right: none;
border-top-color: transparent;
border-bottom-color: transparent
}
.tip-west .tip-arrow {
left: 0;
top: 50%;
margin-top: -5px;
border-right-style: solid;
border-left: none;
border-top-color: transparent;
border-bottom-color: transparent
}
.tip-north-west .tip-arrow,
.tip-south-west .tip-arrow {
left: 15px;
}
.tip-north-east .tip-arrow,
.tip-south-east .tip-arrow {
left: 85%;
}
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
src: local('Source Sans Pro'), local('SourceSansPro-Regular'),
url('/fonts/SourceSansPro-Regular.woff') format('woff');
}
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
src: local('Source Sans Pro Semibold'), local('SourceSansPro-Semibold'),
url('/fonts/SourceSansPro-Semibold.woff') format('woff');
}
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 700;
src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'),
url('/fonts/SourceSansPro-Bold.woff') format('woff');
}
body {
height: 100%;
background: linear-gradient(135deg, #d7bcab 0%, #cc9485 100%);
background-repeat: no-repeat;
background-attachment: fixed;
font-family: 'Source Sans Pro', sans-serif;
padding: 100px;
color: #64584c;
}
ul {
list-style-type: none;
padding: 0;
}
ul li {
padding: 0;
}
h2 {
font-size: 16px;
text-transform: uppercase;
}
.box {
background: #fff;
box-shadow: 2px 4px 6px rgba(0,0,0,0.2);
}
.box.generic,
.box.info,
.box.error,
.box.success {
border-top: 4px solid #eac85d;
padding: 20px;
width: 50%;
margin: 0 auto;
}
.box.info {
border-top-color: #5f90b0;
}
.box.error {
border-top-color: #e45e39;
}
.box.success {
border-top-color: #4db07a;
}
.box a {
color: #64584c;
}
.box h1 {
margin: 0;
padding: 20px;
color: #64584c;
font-size: 20px;
text-transform: uppercase;
}
#graph {
height: 200px;
position: relative;
}
#graph #tooltip {
position: absolute;
top: 0;
left: 0;
}
#graph svg path.line {
fill: none;
stroke-width: 1px;
clip-path: url("app/styles/#clip");
}
#graph svg path.line.actual {
stroke: #64584c;
stroke-width: 3px;
}
#graph svg path.line.ideal {
stroke: #cacaca;
stroke-width: 3px;
}
#graph svg path.line.trendline {
stroke: #64584c;
stroke-width: 1.5px;
stroke-dasharray: 5, 5;
}
#graph svg line.today {
stroke: #cacaca;
stroke-width: 1px;
shape-rendering: crispEdges;
stroke-dasharray: 5, 5;
}
#graph svg circle {
fill: #64584c;
stroke: transparent;
stroke-width: 15px;
cursor: pointer;
}
#graph svg .axis {
shape-rendering: crispEdges;
}
#graph svg .axis line {
stroke: rgba(202,202,202,0.25);
shape-rendering: crispEdges;
}
#graph svg .axis text {
font-weight: bold;
fill: #cacaca;
}
#graph svg .axis path {
display: none;
}
#progress {
padding: 20px;
border-radius: 0 0 6px 6px;
}
#progress:after {
clear: both;
display: block;
content: "";
}
#progress .bars {
position: relative;
}
#progress .bars div {
border-radius: 6px;
height: 12px;
}
#progress .bars div.closed {
position: absolute;
top: 0;
left: 0;
background: #4daf7c;
}
#progress .bars div.closed:not(.done) {
border-radius: 6px 0 0 6px;
}
#progress .bars div.opened {
width: 100%;
background: #e55f3a;
}
#progress h2 {
margin: 10px 0 0 0;
}
#progress h2.closed {
float: left;
color: #4daf7c;
}
#progress h2.opened {
float: right;
color: #e55f3a;
}

28646
build/build.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +0,0 @@
github_user: 'intermine'
github_project: 'intermine'
project_name: 'Core InterMine Project'
weekend: [ 6, 7 ]
base_url: 'github-burndown-chart'
api_token: false

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@ -1,15 +1,38 @@
{
"name": "github-burndown-chart",
"version": "0.2.1",
"version": "1.0.0-alpha",
"description": "Shows a burndown chart for GitHub Issues",
"directories": {
"test": "test"
},
"dependencies": {
"coffee-script": "~1.6.3",
"flatiron": "~0.3.8",
"union": "~0.3.7",
"connect": "~2.8.4",
"eco": "~1.1.0-rc-3",
"js-yaml": "~2.1.0"
"async": "~0.2.9",
"proxyquire": "~0.5.1",
"lodash": "~1.3.1",
"connect": "~2.8.5",
"request": "~2.27.0"
},
"devDependencies": {
"mocha": "~1.12.0"
},
"scripts": {
"start": "start.js"
"test": "make test"
},
"repository": {
"type": "git",
"url": "git://github.com/radekstepan/github-burndown-chart.git"
},
"keywords": [
"github",
"issues",
"burndown",
"chart",
"scrum"
],
"author": "Radek <dev@radekstepan.com>",
"license": "BSD",
"bugs": {
"url": "https://github.com/radekstepan/github-burndown-chart/issues"
}
}

46
proxy.coffee Normal file
View File

@ -0,0 +1,46 @@
#!/usr/bin/env coffee
{ _ } = require 'lodash'
http = require 'http'
fs = require 'fs'
connect = require 'connect'
request = require 'request'
# Read the original config.
config = JSON.parse fs.readFileSync './config.json', 'utf-8'
# Some defaults.
config.host ?= 'api.github.com'
# This is the scrubbed version.
_.extend scrubbed = {}, config, { 'protocol': 'http', 'token': null }
proxy = (req, res, next) ->
write = (code, body) ->
res.writeHead code, {'Content-Type': 'application/json; charset=utf-8'}
res.end body
# Config?
if req.url is '/config.json'
# Refer to us like so.
scrubbed.host = req.headers.host
return write 200, JSON.stringify scrubbed, null, 4
# API request?
if req.url.match /^\/repos/
# The new headers.
headers = 'Accept': 'application/vnd.github.raw'
# Add a token?
headers.Authorization = 'token ' + config.token if config.token
# Make the HTTPS request.
return request {
'uri': 'https://' + config.host + req.url
headers
}, (_err, _res, body) ->
return write(500) if _err
write _res.statusCode, body
# Get handled by Connect.
next()
app = connect()
.use(proxy)
.use(connect.static(__dirname + '/public'))
.listen process.env.PORT

1
public/build.css Symbolic link
View File

@ -0,0 +1 @@
../build/build.css

1
public/build.js Symbolic link
View File

@ -0,0 +1 @@
../build/build.js

File diff suppressed because one or more lines are too long

View File

@ -1,231 +0,0 @@
.rickshaw_graph .detail {
pointer-events: none;
position: absolute;
top: 0;
z-index: 2;
background: rgba(0, 0, 0, 0.1);
bottom: 0;
width: 1px;
transition: opacity 0.25s linear;
-moz-transition: opacity 0.25s linear;
-o-transition: opacity 0.25s linear;
-webkit-transition: opacity 0.25s linear;
}
.rickshaw_graph .detail.inactive {
opacity: 0;
}
.rickshaw_graph .detail .item.active {
opacity: 1;
}
.rickshaw_graph .detail .x_label {
font-family: Arial, sans-serif;
border-radius: 3px;
padding: 6px;
opacity: 0.5;
border: 1px solid #e0e0e0;
font-size: 12px;
position: absolute;
background: white;
white-space: nowrap;
}
.rickshaw_graph .detail .item {
position: absolute;
z-index: 2;
border-radius: 3px;
padding: 0.25em;
font-size: 12px;
font-family: Arial, sans-serif;
opacity: 0;
background: rgba(0, 0, 0, 0.4);
color: white;
border: 1px solid rgba(0, 0, 0, 0.4);
margin-left: 1em;
margin-top: -1em;
white-space: nowrap;
}
.rickshaw_graph .detail .item.active {
opacity: 1;
background: rgba(0, 0, 0, 0.8);
}
.rickshaw_graph .detail .item:before {
content: "\25c2";
position: absolute;
left: -0.5em;
color: rgba(0, 0, 0, 0.7);
width: 0;
}
.rickshaw_graph .detail .dot {
width: 4px;
height: 4px;
margin-left: -4px;
margin-top: -3px;
border-radius: 5px;
position: absolute;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
background: white;
border-width: 2px;
border-style: solid;
display: none;
background-clip: padding-box;
}
.rickshaw_graph .detail .dot.active {
display: block;
}
.rickshaw_graph .detail ul {
list-style-type: none;
margin: 0 5px;
}
/* graph */
.rickshaw_graph {
position: relative;
}
.rickshaw_graph svg {
display: block;
overflow: hidden;
}
/* ticks */
.rickshaw_graph .x_tick {
position: absolute;
top: 0;
bottom: 0;
width: 0px;
border-left: 1px dotted rgba(0, 0, 0, 0.2);
pointer-events: none;
}
.rickshaw_graph .x_tick .title {
position: absolute;
font-size: 12px;
font-family: Arial, sans-serif;
opacity: 0.5;
white-space: nowrap;
margin-left: 3px;
bottom: 1px;
}
/* annotations */
.rickshaw_annotation_timeline {
height: 1px;
border-top: 1px solid #e0e0e0;
margin-top: 10px;
position: relative;
}
.rickshaw_annotation_timeline .annotation {
position: absolute;
height: 6px;
width: 6px;
margin-left: -2px;
top: -3px;
border-radius: 5px;
background-color: rgba(0, 0, 0, 0.25);
}
.rickshaw_graph .annotation_line {
position: absolute;
top: 0;
bottom: -6px;
width: 0px;
border-left: 2px solid rgba(0, 0, 0, 0.3);
display: none;
}
.rickshaw_graph .annotation_line.active {
display: block;
}
.rickshaw_annotation_timeline .annotation .content {
background: white;
color: black;
opacity: 0.9;
padding: 5px 5px;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.8);
border-radius: 3px;
position: relative;
z-index: 20;
font-size: 12px;
padding: 6px 8px 8px;
top: 18px;
left: -11px;
width: 160px;
display: none;
cursor: pointer;
}
.rickshaw_annotation_timeline .annotation .content:before {
content: "\25b2";
position: absolute;
top: -11px;
color: white;
text-shadow: 0 -1px 1px rgba(0, 0, 0, 0.8);
}
.rickshaw_annotation_timeline .annotation.active,
.rickshaw_annotation_timeline .annotation:hover {
background-color: rgba(0, 0, 0, 0.8);
cursor: none;
}
.rickshaw_annotation_timeline .annotation .content:hover {
z-index: 50;
}
.rickshaw_annotation_timeline .annotation.active .content {
display: block;
}
.rickshaw_annotation_timeline .annotation:hover .content {
display: block;
z-index: 50;
}
.rickshaw_graph .y_axis {
fill: none;
}
.rickshaw_graph .y_ticks .tick {
stroke: rgba(0, 0, 0, 0.16);
stroke-width: 2px;
shape-rendering: crisp-edges;
pointer-events: none;
}
.rickshaw_graph .y_grid .tick {
z-index: -1;
stroke: rgba(0, 0, 0, 0.20);
stroke-width: 1px;
stroke-dasharray: 1 1;
}
.rickshaw_graph .y_grid path {
fill: none;
stroke: none;
}
.rickshaw_graph .y_ticks path {
fill: none;
stroke: #808080;
}
.rickshaw_graph .y_ticks text {
opacity: 0.5;
font-size: 12px;
pointer-events: none;
}
.rickshaw_graph .x_tick.glow .title,
.rickshaw_graph .y_ticks.glow text {
fill: black;
color: black;
text-shadow:
-1px 1px 0 rgba(255, 255, 255, 0.1),
1px -1px 0 rgba(255, 255, 255, 0.1),
1px 1px 0 rgba(255, 255, 255, 0.1),
0px 1px 0 rgba(255, 255, 255, 0.1),
0px -1px 0 rgba(255, 255, 255, 0.1),
1px 0px 0 rgba(255, 255, 255, 0.1),
-1px 0px 0 rgba(255, 255, 255, 0.1),
-1px -1px 0 rgba(255, 255, 255, 0.1);
}
.rickshaw_graph .x_tick.inverse .title,
.rickshaw_graph .y_ticks.inverse text {
fill: white;
color: white;
text-shadow:
-1px 1px 0 rgba(0, 0, 0, 0.8),
1px -1px 0 rgba(0, 0, 0, 0.8),
1px 1px 0 rgba(0, 0, 0, 0.8),
0px 1px 0 rgba(0, 0, 0, 0.8),
0px -1px 0 rgba(0, 0, 0, 0.8),
1px 0px 0 rgba(0, 0, 0, 0.8),
-1px 0px 0 rgba(0, 0, 0, 0.8),
-1px -1px 0 rgba(0, 0, 0, 0.8);
}

View File

@ -1,209 +0,0 @@
@font-face {
font-family: 'OpenSansLight';
src: url('../font/OpenSans-Light-webfont.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'OpenSansRegular';
src: url('../font/OpenSans-Regular-webfont.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
body {
background-color: #fff;
background-image: url("../img/colbg.jpg");
background-repeat: repeat-x;
background-position: 0px 40px;
overflow-y: auto;
}
body#landingBody {
background-position: 0px 0px;
}
.navbar .brand i {
margin-top: 5px;
}
.navbar .brand {
color: #fff;
-webkit-transition: color .2s ease-in-out;
cursor: pointer;
}
.navbar .brand:hover {
color: rgba(0,215,249,1);
}
.navbar .nav a:hover {
-webkit-transition: color .3s ease-in-out;
}
.navbar .navbar-inner {
box-shadow: none;
}
.navbar.blue .navbar-inner {
background: #0956ae;
background: -moz-linear-gradient(top, #0956ae 0%, #024a9e 100%);
background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#0956ae), color-stop(100%,#024a9e));
background: -webkit-linear-gradient(top, #0956ae 0%,#024a9e 100%);
background: -o-linear-gradient(top, #0956ae 0%,#024a9e 100%);
background: -ms-linear-gradient(top, #0956ae 0%,#024a9e 100%);
background: linear-gradient(top, #0956ae 0%,#024a9e 100%);
}
.navbar.blue .nav > li > a {
color: #fff;
opacity: 0.8;
-webkit-transition: opacity .3s ease-in-out;
}
.navbar.blue .nav > li > a:hover {
opacity: 1;
}
.navbar.blue .nav > .active > a {
opacity: 1;
background-color: rgba(0,0,0,0.2);
}
.navbar .nav i {
margin-top: 1px;
}
.navbar .nav:last-child i {
margin-top: 2px;
}
.badge-nav {
top: 4px;
background-color: #FFF;
color: #444;
-webkit-border-radius: 1px;
-moz-border-radius: 1px;
border-radius: 1px;
text-shadow: none;
}
.sideBar {
padding-top: 20px;
}
.sideBar ul {
list-style: none;
padding: 0;
margin: 0;
}
.sideBar ul > li {
font-family: "OpenSansLight";
color: #222;
cursor: pointer;
font-size: 16px;
margin-bottom: 2px;
}
.sideBar ul > li figure {
margin: 0;
padding: 6px 14px 6px 14px;
opacity: 0.7;
}
.sideBar ul > li figure:hover {
background-color: #F9F9F9;
opacity: 1;
}
.sideBar ul > li.active figure:first-child {
color: #00AFDB;
opacity: 1;
}
.sideBar ul.subSide {
padding: 5px 0px 5px 0px;
display: none;
}
.sideBar ul.subSide li {
font-size: 15px;
line-height: 17px;
margin-bottom: 5px;
padding: 4px 10px 5px 14px;
margin-left: 10px;
}
.sideBar ul.subSide li:hover {
background-color: rgba(0,175,219,0.1);
}
.sideBar ul li i {
margin-top: 4px;
}
.sideBar ul > li > figure .badge {
top: -2px;
position: relative;
font-family: "OpenSansRegular";
-webkit-border-radius: 1px;
-moz-border-radius: 1px;
border-radius: 1px;
text-shadow: none;
}
.borBox {
-webkit-box-sizing: border-box;
-ms-box-sizing: border-box;
-moz-box-sizing: border-box;
-o-box-sizing: border-box;
box-sizing: border-box;
}
.pad40 {
padding-top: 40px;
}
hr {
margin: 7px 0px;
}
.content {
padding: 20px;
}
.btn-sharp {
-webkit-border-radius: 0px;
-moz-border-radius: 0px;
border-radius: 0px;
}
.sharp .btn:first-child {
-webkit-border-radius: 1px;
-moz-border-radius: 1px;
border-radius: 1px;
}
.sharp .btn:last-child {
-webkit-border-radius: 1px;
-moz-border-radius: 1px;
border-radius: 1px;
}
.btn-dashboard {
padding: 20px;
height: 100px;
-webkit-border-radius: 2px;
-moz-border-radius: 2px;
border-radius: 2px;
}
.tLeft {
text-align: left;
}
.tRight {
text-align: right;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

19
public/index.html Normal file
View File

@ -0,0 +1,19 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>GitHub Burndown Chart</title>
<link href="build.css" media="all" rel="stylesheet" type="text/css" />
<script src="build.js"></script>
<script>
document.onreadystatechange = function() {
if (document.readyState == "complete") {
require('app').call(null);
}
};
</script>
</head>
<body>
</body>
</html>

File diff suppressed because one or more lines are too long

2
public/js/d3.min.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

39
src/app.coffee Normal file
View File

@ -0,0 +1,39 @@
#!/usr/bin/env coffee
async = require 'async'
{ _ } = require 'lodash'
config = require './modules/config'
regex = require './modules/regex'
render = require './modules/render'
repo = require './modules/repo'
# Check for a route.
route = ->
# Do we have a location match?
if match = window.location.hash.match regex.location
# Get the user/repo pair then.
path = match[1..3].join('/')
# Say we are loading this repo then.
render 'body', 'loading', { path }
# Get config/cache.
return async.waterfall [ config
# Render this repo.
, (conf, cb) ->
repo _.extend({ path }, conf), cb
], (err) ->
render 'body', 'error', { 'text': err.toString() } if err
# Info notice for you.
render 'body', 'info'
module.exports = ->
# Do we have browser support?
if 'onhashchange' of window and 'hash' of window.location
# Detect route changes.
window.addEventListener 'hashchange', route, no
# And route now.
return do route
render 'body', 'error', { 'text': 'URL fragment identifier not supported' }

37
src/component.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "app",
"main": "app.js",
"version": "1.0.0-alpha",
"dependencies": {
"bestiejs/lodash": "*",
"caolan/async": "*",
"mbostock/d3": "*",
"visionmedia/superagent": "*",
"necolas/normalize.css": "*",
"component/tip": "*",
"component/aurora-tip": "*"
},
"scripts": [
"app.coffee",
"modules/config.coffee",
"modules/graph.coffee",
"modules/issues.coffee",
"modules/milestones.coffee",
"modules/regex.coffee",
"modules/request.coffee",
"modules/render.coffee",
"modules/repo.coffee",
"templates/error.eco",
"templates/graph.eco",
"templates/info.eco",
"templates/label.eco",
"templates/loading.eco",
"templates/progress.eco"
],
"styles": [
"styles/fonts.css",
"styles/app.styl"
]
}

65
src/modules/config.coffee Normal file
View File

@ -0,0 +1,65 @@
#!/usr/bin/env coffee
{ _ } = require 'lodash'
request = require './request'
regex = require './regex'
# Have it?
config = null
# We are cold.
wait = no
# Callbacks go here.
queue = []
# Defaults.
defaults =
# You do know we work with GitHub right?
'host': 'api.github.com'
# Making NSA (err taxpayer) work for it.
'protocol': 'https'
# Validators of config fields.
validators =
'host': (value) ->
_.isString value
'protocol': (value) ->
_.isString(value) and value.match /^http(s?)$/
'token': (value) ->
_.isString value
'off_days': (value) ->
return no unless _.isArray value
( return no for day in value when day not in [ 1..7 ] )
yes
# Get (& cache) configuration from the server.
module.exports = (cb) ->
# Have config?
return cb null, config if config
# Enqueue.
queue.push cb
# Load it?
unless wait
# Everyone else wait now.
wait = yes
# Make the request.
request.config (err, result) ->
# We do not strictly require config files.
config = ( if err then { } else result )
# Tack on defaults?
( config[k] ?= v for k, v of defaults )
# RegExpify the size label?
if config.size_label
config.size_label = new RegExp config.size_label
else
config.size_label = regex.size_label
# Validate it.
for field, validator of validators when config[field]
unless validator config[field]
return cb "Config field `#{field}` misconfigured"
# Call back for each enqueued.
_.each queue, (cb) ->
cb null, config

247
src/modules/graph.coffee Normal file
View File

@ -0,0 +1,247 @@
#!/usr/bin/env coffee
{ _ } = require 'lodash'
d3 = require 'd3'
Tip = require 'tip'
reg = require './regex'
module.exports =
# A graph of closed issues.
'actual': (collection, created_at, total, cb) ->
head = [ {
date: new Date(created_at)
points: total
} ]
min = +Infinity ; max = -Infinity
# Generate the actual closes.
rest = _.map collection, (issue) ->
{ size, closed_at } = issue
# Determine the range.
min = size if size < min
max = size if size > max
# Dropping points remaining.
_.extend {}, issue,
date: new Date(closed_at)
points: total -= size
# Now add a radius in a range (will be used for a circle).
range = d3.scale.linear().domain([ min, max ]).range([ 5, 8 ])
rest = _.map rest, (issue) ->
issue.radius = range issue.size
issue
cb null, [].concat head, rest
# A graph of an ideal progression..
'ideal': (a, b, off_days, total, cb) ->
# Swap?
[ b, a ] = [ a, b ] if b < a
# We start here adding days to `d`.
[ y, m, d ] = _.map a.match(reg.datetime)[1].split('-'), (v) -> parseInt v
# We want to end here.
cutoff = new Date(b)
# Go through the beginning to the end skipping off days.
days = [] ; length = 0
do once = (inc = 0) ->
# A new day.
day = new Date y, m - 1, d + inc
# Does this day count?
day_of = 7 if !day_of = day.getDay()
if day_of in off_days
days.push { date: day, off_day: yes }
else
length += 1
days.push { date: day }
# Go again?
once(inc + 1) unless day > cutoff
# Map points on the array of days now.
velocity = total / (length - 1)
days = _.map days, (day, i) ->
day.points = total
total -= velocity if days[i] and not days[i].off_day
day
# Do we need to make a link to right now?
days.push { date: now, points: 0 } if (now = new Date()) > cutoff
cb null, days
# Graph representing a trendling of actual issues.
'trendline': (actual, created_at, due_on) ->
start = +actual[0].date
# Values is a list of time from the start and points remaining.
values = _.map actual, ({ date, points }) ->
[ +date - start, points ]
# Now is an actual point too.
last = actual[actual.length - 1]
values.push [ + new Date() - start, last.points ]
# http://classroom.synonym.com/calculate-trendline-2709.html
b1 = 0 ; e = 0 ; c1 = 0
a = (l = values.length) * _.reduce(values, (sum, [ a, b ]) ->
b1 += a ; e += b
c1 += Math.pow(a, 2)
sum + (a * b)
, 0)
slope = (a - (b1 * e)) / ((l * c1) - (Math.pow(b1, 2)))
intercept = (e - (slope * b1)) / l
fn = (x) -> slope * x + intercept
a = +new Date(created_at) - start
b = +new Date(due_on) - start
[
{
date: new Date(created_at)
points: fn(a)
}, {
date: new Date(due_on)
points: fn(b)
}
]
# The graph as a whole.
'render': ([ actual, ideal, trendline ], cb) ->
document.querySelector('#svg').innerHTML = ''
# Get available space.
{ height, width } = document.querySelector('#graph').getBoundingClientRect()
margin = { top: 30, right: 30, bottom: 40, left: 50 }
width -= margin.left + margin.right
height -= margin.top + margin.bottom
# Scales.
x = d3.time.scale().range([ 0, width ])
y = d3.scale.linear().range([ height, 0 ])
# Axes.
xAxis = d3.svg.axis().scale(x)
.orient("bottom")
# Show vertical lines...
.tickSize(-height)
# ...with day of the month...
.tickFormat( (d) -> d.getDate() )
# ...and give us a spacer.
.tickPadding(10)
yAxis = d3.svg.axis().scale(y)
.orient("left")
.tickSize(-width)
.ticks(5)
.tickPadding(10)
# Line generator.
line = d3.svg.line()
.interpolate("linear")
.x( (d) -> x(d.date) )
.y( (d) -> y(d.points) )
# Get the minimum and maximum date, and initial points.
x.domain([ ideal[0].date, ideal[ideal.length - 1].date ])
y.domain([ 0, ideal[0].points ]).nice()
# Add an SVG element with the desired dimensions and margin.
svg = d3.select("#svg").append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")")
# Add the days x-axis.
svg.append("g")
.attr("class", "x axis day")
.attr("transform", "translate(0,#{height})")
.call(xAxis)
# Add the months x-axis.
m = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
]
mAxis = xAxis
.orient("top")
.tickSize(height)
.tickFormat( (d) -> m[d.getMonth()] )
.ticks(2)
svg.append("g")
.attr("class", "x axis month")
.attr("transform", "translate(0,#{height})")
.call(mAxis)
# Add the y-axis.
svg.append("g")
.attr("class", "y axis")
.call(yAxis)
# Add a line showing where we are now.
svg.append("svg:line")
.attr("class", "today")
.attr("x1", x(new Date()))
.attr("y1", 0)
.attr("x2", x(new Date()))
.attr("y2", height)
# Add the ideal line path.
svg.append("path")
.attr("class", "ideal line")
.attr("d", line.interpolate("basis")(ideal))
# Add the trendline path.
svg.append("path")
.attr("class", "trendline line")
.attr("d", line.interpolate("linear")(trendline))
# Add the actual line path.
svg.append("path")
.attr("class", "actual line")
.attr("d", line.interpolate("linear").y( (d) -> y(d.points) )(actual))
# Collect the tooltip here.
tooltip = null
# Show when we closed an issue.
svg.selectAll("a.issue")
.data(actual[1...]) # skip the starting point
.enter()
# A wrapping link.
.append('svg:a')
.attr("xlink:href", ({ html_url }) -> html_url )
.attr("xlink:show", 'new')
.append('svg:circle')
.attr("cx", ({ date }) -> x date )
.attr("cy", ({ points }) -> y points )
.attr("r", ({ radius }) -> 5 ) # fixed for now
.on('mouseover', ({ date, points, title, number }) ->
# Pass a title string.
tooltip = new Tip "##{number}: #{title}"
# Absolutely position the div.
div = document.querySelector '#tooltip'
div.style.left = x(date) + margin.left + 'px'
div.style.top = -10 + y(points) + margin.top + 'px'
# And now show us on the div.
tooltip.show '#tooltip'
)
.on('mouseout', (d) ->
# Hide after a time has passed if exists.
tooltip?.hide(200)
)
cb null

64
src/modules/issues.coffee Normal file
View File

@ -0,0 +1,64 @@
#!/usr/bin/env coffee
{ _ } = require 'lodash'
async = require 'async'
req = require './request'
reg = require './regex'
module.exports =
# Used on an initial fetch of issues for a repo.
'get_all': (repo, cb) ->
# For each state...
one_status = (state, cb) ->
# Concat them here.
results = []
# One pageful fetch (next pages in series).
do fetch_page = (page = 1) ->
req.all_issues repo, {
milestone: repo.milestone.number
state
page
}, (err, data) ->
# Request errors.
return cb err if err
# GitHub errors.
return cb data.message if data.message
# Empty?
return cb null, results unless data.length
# Concat sorted (API does not sort on closed_at!).
results = results.concat _.sortBy data, 'closed_at'
# < 100 results?
return cb null, results if data.length < 100
# Fetch the next page then.
fetch_page page + 1
# For each `open` and `closed` issues in parallel.
async.parallel [
_.partial one_status, 'open'
_.partial one_status, 'closed'
], cb
# Filter an array of incoming issues based on a regex & save size on them.
'filter': (collection, regex, cb) ->
warnings = null ; total = 0
try
filtered = _.filter collection, (issue) ->
{ labels, number } = issue
number ?= '?'
return false unless labels
switch ( {} for { name } in labels when name and regex.test(name) ).length
when 0 then false
when 1
# Provide the size attribute on the issue.
total += issue.size = parseInt name.match(regex)[1]
true
else
warnings ?= []
warnings.push "Issue ##{number} has multiple matching size labels"
true
cb null, warnings, filtered, total
catch err
return cb err, warnings

View File

@ -0,0 +1,20 @@
#!/usr/bin/env coffee
request = require './request'
module.exports =
# Get current milestones for a repo..
'get_current': (repo, cb) ->
request.all_milestones repo, (err, data) ->
# Request errors?
return cb err if err
# GitHub errors?
return cb data.message if data.message
# Empty warning?
return cb null, 'No open milestones for repo' unless data.length
# The first milestone should be ending soonest.
m = data[0]
# Empty milestone?
return cb null, 'No issues for milestone' if m.open_issues + m.closed_issues is 0
cb null, null, m

8
src/modules/regex.coffee Normal file
View File

@ -0,0 +1,8 @@
#!/usr/bin/env coffee
module.exports =
# How do we parse GitHub dates?
'datetime': /^(\d{4}-\d{2}-\d{2})T(.*)/
# How does a size label look like?
'size_label': /^size (\d+)$/
# How do we specify which user/repo we want?
'location': /^#!\/(.+)\/(.+)$/

View File

@ -0,0 +1,6 @@
#!/usr/bin/env coffee
# Render an eco template into a selector (innerHTML).
module.exports = (selector, template, context = {}) ->
tml = require "../templates/#{template}"
document.querySelector(selector).innerHTML = tml context

80
src/modules/repo.coffee Normal file
View File

@ -0,0 +1,80 @@
#!/usr/bin/env coffee
{ _ } = require 'lodash'
async = require 'async'
milestones = require './milestones'
issues = require './issues'
graph = require './graph'
regex = require './regex'
render = require './render'
# Setup a repo and render it.
module.exports = (opts, cb) ->
# Get the current milestone.
async.waterfall [ (cb) ->
milestones.get_current opts, (err, warn, milestone) ->
return cb err if err
return cb warn if warn
opts.milestone = milestone
cb null
# Get all issues.
(cb) ->
issues.get_all opts, cb
# Filter them to labeled ones.
(all, cb) ->
async.map all, (array, cb) ->
issues.filter array, opts.size_label, (err, warn, filtered, total) ->
cb err, [ filtered, total ]
, (err, [ open, closed ]) ->
return cb err if err
# Empty?
return cb 'No matching issues found' if open[1] + closed[1] is 0
# Save the open/closed on us first.
opts.issues =
closed: { points: closed[1], data: closed[0] }
open: { points: open[1], data: open[0] }
cb null
# Create actual and ideal lines & render.
(cb) ->
progress = 100 * opts.issues.closed.points /
(total = opts.issues.open.points + opts.issues.closed.points)
async.parallel [
_.partial(
graph.actual,
opts.issues.closed.data,
opts.milestone.created_at,
total
)
_.partial(
graph.ideal,
opts.milestone.created_at,
opts.milestone.due_on,
opts.off_days or [],
total
)
], (err, values) ->
# Render the body.
render 'body', 'graph', name: opts.repo
# Render the progress.
render '#progress', 'progress', { progress }
# Generate a trendline?
values.push(graph.trendline(
values[0],
opts.milestone.created_at,
opts.milestone.due_on
)) if values[0].length
# Render the chart.
do doit = -> graph.render values, cb
# Watch window resize from now on?
window.onresize = doit if 'onresize' of window
], cb

View File

@ -0,0 +1,56 @@
#!/usr/bin/env coffee
sa = require 'superagent'
{ _ } = require 'lodash'
# Custom JSON parser.
sa.parse =
'application/json': (res) ->
try
JSON.parse res
catch e
{} # it was not to be...
module.exports =
# Get all milestones.
'all_milestones': (repo, cb) ->
query = { 'state': 'open', 'sort': 'due_date', 'direction': 'asc' }
request repo, query, 'milestones', cb
# Get all issues for a state.
'all_issues': (repo, query, cb) ->
_.extend query, { 'per_page': '100' }
request repo, query, 'issues', cb
# Get config from our host always.
'config': (cb) ->
sa
.get("http://#{window.location.host}/config.json")
.set('Content-Type', 'application/json')
.end _.partialRight respond, cb
# Make a request using SuperAgent to GitHub.
request = ({ protocol, host, token, path }, query, noun, cb) ->
# Make the query params.
q = ( "#{k}=#{v}" for k, v of query ).join('&')
req = sa
# The URI.
.get("#{protocol}://#{host}/repos/#{path}/#{noun}?#{q}")
# The content type.
.set('Content-Type', 'application/json')
# The media type.
.set('Accept', 'application/vnd.github.raw')
# Auth token?
req = req.set('Authorization', "token #{token}") if token
# Send.
req.end _.partialRight respond, cb
# How do we respond to a response?
respond = (data, cb) ->
# 2xx?
return cb data.error.message if data.statusType isnt 2
# All good.
cb null, data?.body

166
src/styles/app.styl Normal file
View File

@ -0,0 +1,166 @@
// color definitions
$closed = #4DAF7C
$opened = #E55F3A
$grey = #CACACA
$brown = #64584C
// font and gradient bg
body
height: 100%
background: linear-gradient(135deg, #d7bcab 0%, #cc9485 100%)
background-repeat: no-repeat
background-attachment: fixed
font-family: 'Source Sans Pro', sans-serif
padding: 100px
color: $brown
ul
list-style-type: none
padding: 0
li
padding: 0
h2
font-size: 16px
text-transform: uppercase
// the white content box
.box
background: #FFF
box-shadow: 2px 4px 6px rgba(0,0,0,0.2)
// different classes thereof
&.generic, &.info, &.error, &.success
border-top: 4px solid #EAC85D
padding: 20px
width: 50%
margin: 0 auto
&.info
border-top-color: #5F90B0
&.error
border-top-color: #E45E39
&.success
border-top-color: #4DB07A
a
color: $brown
h1
margin: 0
padding: 20px
color: $brown
font-size: 20px
text-transform: uppercase
// where D3 renders to
#graph
height: 200px
position: relative
// position will be adjusted dynamically
#tooltip
position: absolute
top: 0
left: 0
svg
path
&.line
fill: none
stroke-width: 1px
clip-path: url(#clip)
// actual progress
&.actual
stroke: $brown
stroke-width: 3px
// ideal velocity throughout the sprint
&.ideal
stroke: $grey
stroke-width: 3px
// trend of actual issue closures
&.trendline
stroke: $brown
stroke-width: 1.5px
stroke-dasharray: 5,5
// right now
line
&.today
stroke: $grey
stroke-width: 1px
shape-rendering: crispEdges
stroke-dasharray: 5,5
// represents one issue closed
circle
fill: $brown
// make it easier to click
stroke: transparent
stroke-width: 15px
cursor: pointer
// axes...
.axis
shape-rendering: crispEdges
line
stroke: rgba($grey, 0.25)
shape-rendering: crispEdges
text
font-weight: bold
fill: $grey
path
display: none
// progression graph
#progress
padding: 20px
border-radius: 0 0 6px 6px
// clear
&:after
clear: both
display: block
content: ""
.bars
position: relative
// the two bars
div
border-radius: 6px
height: 12px
&.closed
position: absolute
top: 0
left: 0
background: $closed
// when we have issues left
&:not(.done)
border-radius: 6px 0 0 6px
&.opened
width: 100%
background: $opened
h2
margin: 10px 0 0 0
&.closed
float: left
color: $closed
&.opened
float: right
color: $opened

21
src/styles/fonts.css Normal file
View File

@ -0,0 +1,21 @@
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
src: local('Source Sans Pro'), local('SourceSansPro-Regular'),
url('/fonts/SourceSansPro-Regular.woff') format('woff');
}
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
src: local('Source Sans Pro Semibold'), local('SourceSansPro-Semibold'),
url('/fonts/SourceSansPro-Semibold.woff') format('woff');
}
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 700;
src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'),
url('/fonts/SourceSansPro-Bold.woff') format('woff');
}

4
src/templates/error.eco Normal file
View File

@ -0,0 +1,4 @@
<div class="box error">
<h2>Trouble</h2>
<p><%- @text %></p>
</div>

8
src/templates/graph.eco Normal file
View File

@ -0,0 +1,8 @@
<div class="box">
<h1><%- @name %></h2>
<div id="graph">
<div id="tooltip"></div>
<div id="svg"></div>
</div>
<div id="progress"></div>
</div>

4
src/templates/info.eco Normal file
View File

@ -0,0 +1,4 @@
<div class="box info">
<h2>GitHub Burndown Chart</h2>
<p>Use your browser's location hash to specify a repo: <a href="#!/radekstepan/disposable">#!/radekstepan/disposable</a>.</p>
</div>

10
src/templates/label.eco Normal file
View File

@ -0,0 +1,10 @@
<% points = Math.ceil @points %>
<% if points > 1: %>
<%- points %> points left
<% else: %>
<% if points is 1: %>
1 point left
<% else: %>
Done
<% end %>
<% end %>

View File

@ -0,0 +1,4 @@
<div class="box generic">
<h2>GitHub Burndown Chart</h2>
<p>Loading <a href="#!/<%- @path %>">#!/<%- @path %></a>.</p>
</div>

View File

@ -0,0 +1,10 @@
<div class="bars">
<% if @progress is 100: %>
<div class="closed done" style="width:100%"></div>
<% else: %>
<div class="closed" style="width:<%= @progress %>%"></div>
<% end %>
<div class="opened"></div>
</div>
<h2 class="closed">Closed / <%= Math.floor @progress %>%</h2>
<h2 class="opened">Open / <%= 100 - Math.floor @progress %>%</h2>

View File

@ -1,3 +0,0 @@
#!/usr/bin/env node
require('coffee-script');
require('./app.coffee');

View File

@ -1,162 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta charset="utf-8">
<title>Burndown App</title>
<base href="<%= @base_url %>"/>
<link rel="stylesheet" type="text/css" href="css/bootstrap.min.css">
<link rel="stylesheet" type="text/css" href="css/rickshaw.css">
<link rel="stylesheet" type="text/css" href="css/style.css">
<script src="js/d3.min.js"></script>
<script src="js/d3.layout.min.js"></script>
<script src="js/rickshaw.min.js"></script>
</head>
<body>
<header class="navbar blue navbar-fixed-top">
<div class="navbar-inner">
<div class="container-fluid">
<ul class="nav pull-left">
<li><a><i class="icon-white icon-fire"></i> Burndown App</a></li>
</ul>
<ul class="nav pull-right">
<li><a><i class="icon-white icon-book"></i> <%= @project %></a></li>
</ul>
</div>
</div>
</header>
<section class="container-fluid pad40">
<section class="row-fluid">
<div class="span2 sideBar">
<br>
<ul>
<li class="active">
<figure>
<a href="burndown"><i class="icon-signal"></i> Burndown Chart</a>
</figure>
</li>
<li>
<figure>
<a href="issues"><i class="icon-tasks"></i> Issues</a>
</figure>
</li>
</ul>
</div>
<section class="span10 content borBox">
<div class="row-fluid">
<div class="page-header">
<h1>Burndown chart <small>showing actual vs. ideal velocity on each day</small></h1>
</div>
</div>
<br>
<div class="row-fluid">
<div class="span12">
<div id="chart"></div>
<div id="timeline"></div>
</div>
</div>
</section>
</section>
</section>
<script>
(function() {
var now = + new Date;
var ideal = [], actual = [], issues = {}, i = 0;
<% for day, data of @days: %>
ideal.push({ 'x': <%= day / 1000 %>, 'y': <%= data['ideal'] %> });
actual.push({ 'x': <%= day / 1000 %>, 'y': <%= data['actual'] %> });
// Fill up issues object.
<% if data['issues'].length > 0: %>
issues[<%= day %>] = [];
<% for issue in data['issues']: %>
issues[<%= day %>].push('<%= escape issue['title'] %>');
<% end %>
<% end %>
i++;
<% end %>
// Early exit to avoid Rickshaw error.
if (actual.length == 0) return;
// The line graph.
var graph = new Rickshaw.Graph({
element: document.querySelector("#chart"),
height: 600,
renderer: 'line',
series: [{
data: ideal,
color: '#75ABC5',
name: 'Ideal'
}, {
data: actual,
color: '#F89406',
name: 'Actual'
}]
});
graph.render();
// Onhover.
var hoverDetail = new Rickshaw.Graph.HoverDetail({
graph: graph,
xFormatter: function(d) { return new Date(d * 1000).toUTCString().substring(0, 11) },
formatter: function(series, stamp, points) {
if (series.name == 'Ideal') {
return '<p>Ideally ' + Math.round(points) + ' points left</p>'
} else {
var left = '<p>' + Math.round(points) + ' points left</p>';
// Have we closed any issues today?
var iss = issues[stamp * 1000];
if (iss) {
var string = '<ul>';
for (var i = 0; i < iss.length; i++) {
string += '<li><span class="icon-ok icon-white"></span>&nbsp;&nbsp;' + unescape(iss[i]) + '</li>';
}
string += '</ul>';
return left + string;
} else {
return left;
}
}
}
});
// Axes.
var xAxis = new Rickshaw.Graph.Axis.Time({
graph: graph,
ticksTreatment: 'glow'
});
var yAxis = new Rickshaw.Graph.Axis.Y({
graph: graph,
tickFormat: Rickshaw.Fixtures.Number.formatKMBT,
ticksTreatment: 'glow'
});
xAxis.render();
yAxis.render();
// Annotations.
var annotator = new Rickshaw.Graph.Annotate({
graph: graph,
element: document.getElementById('timeline')
});
annotator.add(now / 1000, 'Now');
annotator.update();
})();
</script>
</body>
</html>

View File

@ -1,56 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta charset="utf-8">
<title>Burndown App</title>
<base href="<%= @base_url %>"/>
<link rel="stylesheet" type="text/css" href="css/bootstrap.min.css">
<link rel="stylesheet" type="text/css" href="css/style.css">
</head>
<body>
<header class="navbar blue navbar-fixed-top">
<div class="navbar-inner">
<div class="container-fluid">
<ul class="nav pull-left">
<li><a><i class="icon-white icon-fire"></i> Burndown App</a></li>
</ul>
<ul class="nav pull-right">
<li><a><i class="icon-white icon-book"></i> <%= @project %></a></li>
</ul>
</div>
</div>
</header>
<section class="container-fluid pad40">
<section class="row-fluid">
<div class="span2 sideBar">
<br>
<ul>
<li class="active">
<figure>
<a href="burndown"><i class="icon-signal"></i> Burndown Chart</a>
</figure>
</li>
<li>
<figure>
<a href="issues"><i class="icon-tasks"></i> Issues</a>
</figure>
</li>
</ul>
</div>
<section class="span10 content borBox">
<div class="row-fluid">
<div class="page-header">
<h1>All issues are closed</h1>
</div>
</div>
</section>
</section>
</section>
</body>
</html>

View File

@ -1,100 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta charset="utf-8">
<title>Burndown App</title>
<base href="<%= @base_url %>"/>
<link rel="stylesheet" type="text/css" href="css/bootstrap.min.css">
<link rel="stylesheet" type="text/css" href="css/style.css">
</head>
<body>
<header class="navbar blue navbar-fixed-top">
<div class="navbar-inner">
<div class="container-fluid">
<ul class="nav pull-left">
<li><a><i class="icon-white icon-fire"></i> Burndown App</a></li>
</ul>
<ul class="nav pull-right">
<li><a><i class="icon-white icon-book"></i> <%= @project %></a></li>
</ul>
</div>
</div>
</header>
<section class="container-fluid pad40">
<section class="row-fluid">
<div class="span2 sideBar">
<br>
<ul>
<li>
<figure>
<a href="burndown"><i class="icon-signal"></i> Burndown Chart</a>
</figure>
</li>
<li class="active">
<figure>
<a href="issues"><i class="icon-tasks"></i> Issues</a>
</figure>
</li>
</ul>
</div>
<section class="span10 content borBox">
<% if @issues.length > 0: %>
<div class="row-fluid">
<div class="page-header">
<h1>List of outstanding issues</h1>
</div>
</div>
<br>
<div class="row-fluid">
<div class="span12">
<table class="table table-striped">
<thead>
<tr>
<th>#</th>
<th>Title</th>
<th>Opened</th>
<th>Updated</th>
<th>Opened By</th>
<th>Assigned To</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<% for issue in @issues: %>
<tr>
<td><%= issue.number %></td>
<td><strong><%= issue.title %></strong></td>
<td><%= issue.created_at %></td>
<td><%= issue.updated_at %></td>
<td><%= issue.user.login %></td>
<td><%= issue.assignee?.login %></td>
<td>
<a target="_new" href="<%= issue.html_url %>">
<button class="btn btn-mini btn-success"><i class="icon-white icon-eye-open"></i></button>
</a>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% else: %>
<div class="row-fluid">
<div class="page-header">
<h1>All issues are closed</h1>
</div>
</div>
<% end %>
</section>
</section>
</section>
</body>
</html>

189
test/issues.coffee Normal file
View File

@ -0,0 +1,189 @@
#!/usr/bin/env coffee
proxy = do require('proxyquire').noCallThru
assert = require 'assert'
path = require 'path'
req = {}
regex = require path.resolve(__dirname, '../src/modules/regex.coffee')
issues = proxy path.resolve(__dirname, '../src/modules/issues.coffee'),
'./request': req
repo = { 'milestone': { 'number': no } }
module.exports =
'issues - all empty': (done) ->
called = 0
req.all_issues = (repo, opts, cb) ->
called += 1
cb null, []
issues.get_all repo, (err, [ open, closed ]) ->
assert.ifError err
assert.equal called, 2
assert.equal open.length, 0
assert.equal closed.length, 0
do done
'issues - open empty': (done) ->
called = 0
req.all_issues = (repo, opts, cb) ->
called += 1
cb null, if called is 1 then [] else [
{ number: 1 }
]
issues.get_all repo, (err, [ open, closed ]) ->
assert.ifError err
assert.equal called, 2
assert.equal open.length, 0
assert.equal closed.length, 1
do done
'issues - closed empty': (done) ->
called = 0
req.all_issues = (repo, opts, cb) ->
called += 1
cb null, if called is 2 then [] else [
{ number: 1 }
]
issues.get_all repo, (err, [ open, closed ]) ->
assert.ifError err
assert.equal called, 2
assert.equal open.length, 1
assert.equal closed.length, 0
do done
'issues - both not empty': (done) ->
called = 0
req.all_issues = (repo, opts, cb) ->
called += 1
cb null, [ { number: 1 } ]
issues.get_all repo, (err, [ open, closed ]) ->
assert.ifError err
assert.equal called, 2
assert.equal open.length, 1
assert.equal closed.length, 1
do done
'issues - 99 results on a page': (done) ->
called = 0
req.all_issues = (repo, opts, cb) ->
called += 1
cb null, ( { number: i } for i in [ 0...99 ] )
issues.get_all repo, (err, [ open, closed ]) ->
assert.ifError err
assert.equal called, 2
assert.equal open.length, 99
assert.equal closed.length, 99
do done
'issues - 100 results on a page': (done) ->
called = 0
req.all_issues = (repo, opts, cb) ->
called += 1
assert opts.page in [ 1, 2 ]
cb null, if opts.page is 1 then ( { number: i } for i in [ 0...100 ] ) else []
issues.get_all repo, (err, [ open, closed ]) ->
assert.ifError err
assert.equal called, 4
assert.equal open.length, 100
assert.equal closed.length, 100
do done
'issues - 101 total results': (done) ->
called = 0
req.all_issues = (repo, opts, cb) ->
called += 1
assert opts.page in [ 1, 2 ]
cb null, if opts.page is 1
( { number: i } for i in [ 0...100 ] )
else
[ { number: 100 } ]
issues.get_all repo, (err, [ open, closed ]) ->
assert.ifError err
assert.equal called, 4
assert.equal open.length, 101
assert.equal closed.length, 101
assert.deepEqual open[100], { number: 100 }
assert.deepEqual closed[100], { number: 100 }
do done
'issues - 201 total results': (done) ->
called = 0
req.all_issues = (repo, opts, cb) ->
called += 1
assert opts.page in [ 1, 2, 3 ]
cb null, if opts.page in [ 1, 2 ]
( { number: i } for i in [ (h = 100 * (opts.page - 1))...h + 100 ] )
else
[ { number: 200 } ]
issues.get_all repo, (err, [ open, closed ]) ->
assert.ifError err
assert.equal called, 6
assert.equal open.length, 201
assert.equal closed.length, 201
for i in [ open, closed ]
for j in [ 100, 200 ]
assert.deepEqual i[j], { number: j }
do done
'issues - get all when not found': (done) ->
called = 0
req.all_issues = (repo, opts, cb) ->
called += 1
cb null, { 'message': 'Not Found' }
issues.get_all repo, (err, [ open, closed ]) ->
assert.equal err, 'Not Found'
assert.equal called, 1
do done
'issues - filter on existing label regex': (done) ->
issues.filter [ { labels: [ { name: 'size 15' } ] } ]
, regex.size_label, (err, warn, data) ->
assert.ifError err
assert.ifError warn
assert.equal data.length, 1
assert.equal data[0].size, 15
do done
'issues - filter when no labels': (done) ->
issues.filter [ { } ]
, regex.size_label, (err, warn, data) ->
assert.ifError err
assert.ifError warn
assert.equal data.length, 0
do done
'issues - filter when empty labels': (done) ->
issues.filter [ { labels: [] } ]
, regex.size_label, (err, warn, data) ->
assert.ifError err
assert.ifError warn
assert.equal data.length, 0
do done
'issues - filter when not matching regex': (done) ->
issues.filter [ { labels: [ { name: 'size 1A' } ] } ]
, regex.size_label, (err, warn, data) ->
assert.ifError err
assert.ifError warn
assert.equal data.length, 0
do done
'issues - filter when multiple match the regex': (done) ->
issues.filter [ { labels: [ { name: 'size 1' }, { name: 'size 6' } ] } ]
, regex.size_label, (err, warn, data) ->
assert.ifError err
assert.equal warn.length, 1
assert.equal data.length, 1
do done

86
test/milestones.coffee Normal file
View File

@ -0,0 +1,86 @@
#!/usr/bin/env coffee
proxy = do require('proxyquire').noCallThru
assert = require 'assert'
path = require 'path'
req = {}
milestones = proxy path.resolve(__dirname, '../src/modules/milestones.coffee'),
'./request': req
module.exports =
'milestones - get current from 1': (done) ->
req.all_milestones = (opts, cb) ->
cb null, [
{
'number': 1
'created_at': '2013-01-01T00:00:00Z'
'due_on': '2013-02-01T00:00:00Z'
}
]
milestones.get_current {}, (err, warn, milestone) ->
assert.ifError err
assert.equal milestone.number, 1
do done
# We always take from head because of request params.
'milestones - get current from > 1': (done) ->
req.all_milestones = (opts, cb) ->
cb null, [
{
'number': 2
'created_at': '2013-01-01T00:00:00Z'
'due_on': '2013-01-15T00:00:00Z'
}
{
'number': 1
'created_at': '2013-01-01T00:00:00Z'
'due_on': '2013-02-01T00:00:00Z'
}
{
'number': 3
'created_at': '2013-01-01T00:00:00Z'
'due_on': '2013-02-15T00:00:00Z'
}
]
milestones.get_current {}, (err, warn, milestone) ->
assert.ifError err
assert.equal milestone.number, 2
do done
'milestones - get current when empty': (done) ->
req.all_milestones = (opts, cb) ->
cb null, []
milestones.get_current {}, (err, warn, milestone) ->
assert.ifError err
assert.equal warn, 'No open milestones for repo'
do done
'milestones - get current when not found': (done) ->
req.all_milestones = (opts, cb) ->
cb null, { 'message': 'Not Found' }
milestones.get_current {}, (err, warn, milestone) ->
assert.equal err, 'Not Found'
do done
'milestones - get current when no issues': (done) ->
req.all_milestones = (opts, cb) ->
cb null, [
{
'number': 1
'created_at': '2013-01-01T00:00:00Z'
'due_on': '2013-02-01T00:00:00Z',
'open_issues': 0,
'closed_issues': 0
}
]
milestones.get_current {}, (err, warn, milestone) ->
assert.ifError err
assert.equal warn, 'No issues for milestone'
do done