Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Github] Last commit date and commit stats #1112

Merged
merged 19 commits into from
Oct 2, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 57 additions & 6 deletions lib/color-formatters.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
*/
'use strict';

function versionFormatter(version) {
const moment = require('moment');

function version(version) {
let first = version[0];
if (first === 'v') {
first = version[1];
Expand All @@ -17,20 +19,17 @@ function versionFormatter(version) {
return { version: version, color: 'blue' };
}
}
exports.version = versionFormatter;

function downloadCount(downloads) {
return floorCount(downloads, 10, 100, 1000);
}
exports.downloadCount = downloadCount;

function coveragePercentage(percentage) {
return floorCount(percentage, 80, 90, 100);
}
exports.coveragePercentage = coveragePercentage;

function floorCount(value, yellow, yellowgreen, green) {
if (value === 0) {
if (value <= 0) {
return 'red';
} else if (value < yellow) {
return 'yellow';
Expand All @@ -42,4 +41,56 @@ function floorCount(value, yellow, yellowgreen, green) {
return 'brightgreen';
}
}
exports.floorCount = floorCount;

function colorScale(steps, colors, reversed) {
if (steps === undefined) {
throw Error('When invoking colorScale, steps should be provided.');
}

const defaultColors = {
1: ['red', 'brightgreen'],
2: ['red', 'yellow', 'brightgreen'],
3: ['red', 'yellow', 'green', 'brightgreen'],
4: ['red', 'yellow', 'yellowgreen', 'green', 'brightgreen'],
5: ['red', 'orange', 'yellow', 'yellowgreen', 'green', 'brightgreen'],
};

if (typeof colors === 'undefined') {
if (steps.length in defaultColors) {
colors = defaultColors[steps.length];
} else {
throw Error(`No default colors for ${steps.length} steps.`);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These should be throw new Error, not throw Error. Invoking constructors without the new keyword is deprecated.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't find a reference supporting this. I used to write new Error but stopped because it's extra tokens for seemingly no value. The ES5 spec specifically permits this. Is there an ES6 reference that says otherwise?

I only bring it up because I'd enforce what you're asking for if I had a good reference.

Copy link
Member

@Daniel15 Daniel15 Oct 2, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, interesting @paulmelnikow! That's still in the ES2015 spec (https://www.ecma-international.org/ecma-262/6.0/#sec-error-constructor):

When Error is called as a function rather than as a constructor, it creates and initializes a new Error object. Thus the function call Error(…) is equivalent to the object creation expression new Error(…) with the same arguments.

I guess it's okay then. I was basing my info off an internal lint rule we have at Facebook, but perhaps the info is outdated/incorrect.

}
}

if (steps.length !== colors.length - 1) {
throw Error('When colors are provided, there should be n + 1 colors for n steps.');
}

if (reversed) {
colors = Array.from(colors).reverse();
}

return value => {
const stepIndex = steps.findIndex(step => value < step);

// For the final step, stepIndex is -1, so in all cases this expression
// works swimmingly.
return colors.slice(stepIndex)[0];
};
}

function age(date) {
const colorByAge = colorScale([7, 30, 180, 365, 730], undefined, true);
const daysElapsed = moment().diff(moment(date), 'days');
return colorByAge(daysElapsed);
}

module.exports = {
version,
downloadCount,
coveragePercentage,
floorCount,
colorScale,
age
};
64 changes: 64 additions & 0 deletions lib/color-formatters.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
'use strict';

const assert = require('assert');
const {
coveragePercentage,
colorScale,
age
} = require('./color-formatters');

describe('Color formatters', function() {
it('should step appropriately', function() {
const byPercentage = colorScale([Number.EPSILON, 80, 90, 100]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the purpose of Number.EPSILON here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The semantics of colorScale are that the color shifts into the new color when the value >= the provided step value. By making this EPSILON, a value of 0.5 produces yellow. If it were 1 it would be red.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you just use 0, or do you want to differentiate 0 from "any possible value after 0"?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existing percentage function makes 0 red and anything after that yellow. I don't see the point in differentiating between 0 and 1, which both seem terrible, though I was aiming for parity.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added tests at 0.5 to clarify this.

assert.equal(byPercentage(-1), 'red');
assert.equal(byPercentage(0), 'red');
assert.equal(byPercentage(0.5), 'yellow');
assert.equal(byPercentage(1), 'yellow');
assert.equal(byPercentage(50), 'yellow');
assert.equal(byPercentage(80), 'yellowgreen');
assert.equal(byPercentage(85), 'yellowgreen');
assert.equal(byPercentage(90), 'green');
assert.equal(byPercentage(100), 'brightgreen');
assert.equal(byPercentage(101), 'brightgreen');
});

it('should have parity with coveragePercentage', function() {
const byPercentage = colorScale([Number.EPSILON, 80, 90, 100]);
assert.equal(byPercentage(-1), coveragePercentage(-1));
assert.equal(byPercentage(0), coveragePercentage(0));
assert.equal(byPercentage(0.5), coveragePercentage(0.5));
assert.equal(byPercentage(1), coveragePercentage(1));
assert.equal(byPercentage(50), coveragePercentage(50));
assert.equal(byPercentage(80), coveragePercentage(80));
assert.equal(byPercentage(85), coveragePercentage(85));
assert.equal(byPercentage(90), coveragePercentage(90));
assert.equal(byPercentage(100), coveragePercentage(100));
assert.equal(byPercentage(101), coveragePercentage(101));
});

it('should step in reverse', function() {
const byAge = colorScale([7, 30, 180, 365, 730], undefined, true);
assert.equal(byAge(3), 'brightgreen');
assert.equal(byAge(7), 'green');
assert.equal(byAge(10), 'green');
assert.equal(byAge(60), 'yellowgreen');
assert.equal(byAge(250), 'yellow');
assert.equal(byAge(400), 'orange');
assert.equal(byAge(800), 'red');
});

it('should generate correct color for ages', function() {
const monthsAgo = months => {
const result = new Date();
// This looks wack but it works.
result.setMonth(result.getMonth() - months);
return result;
};

assert.equal(age(Date.now()), 'brightgreen');
assert.equal(age(new Date()), 'brightgreen');
assert.equal(age(new Date(2001, 1, 1)), 'red');
assert.equal(age(monthsAgo(2)), 'yellowgreen');
assert.equal(age(monthsAgo(15)), 'orange');
});
});
18 changes: 17 additions & 1 deletion lib/text-formatters.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
*/
'use strict';

const moment = require('moment');
moment().format();

function starRating(rating) {
let stars = '';
while (stars.length < rating) { stars += '★'; }
Expand Down Expand Up @@ -60,11 +63,24 @@ function maybePluralize(singular, countable, plural) {
}
}

function formatDate(d) {
const date = moment(d);
const dateString = date.calendar(null, {
lastDay: '[yesterday]',
sameDay: '[today]',
lastWeek: '[last] dddd',
sameElse: 'MMMM YYYY'
});
// Trim current year from date string
return dateString.replace(` ${moment().year()}`, '').toLowerCase();
}

module.exports = {
starRating,
currencyFromCode,
ordinalNumber,
metric,
omitv,
maybePluralize
maybePluralize,
formatDate
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"pretty-bytes": "^3.0.1",
"gm": "^1.23.0",
"json-autosave": "~1.1.2",
"moment": "^2.18.1",
"pdfkit": "~0.8.0",
"redis": "~2.6.2",
"request": "~2.81.0",
Expand Down
88 changes: 87 additions & 1 deletion server.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,15 @@ const {
ordinalNumber,
starRating,
omitv,
maybePluralize
maybePluralize,
formatDate
} = require('./lib/text-formatters');
const {
coveragePercentage: coveragePercentageColor,
downloadCount: downloadCountColor,
floorCount: floorCountColor,
version: versionColor,
age: ageColor
} = require('./lib/color-formatters');
const {
analyticsAutoLoad,
Expand Down Expand Up @@ -3714,6 +3716,90 @@ cache(function(data, match, sendBadge, request) {
});
}));

// GitHub commit statistics integration.
camp.route(/^\/github\/commit-activity\/(y|4w|w)\/([^\/]+)\/([^\/]+)\.(svg|png|gif|jpg|json)$/,
cache(function(data, match, sendBadge, request) {
const interval = match[1];
const user = match[2];
const repo = match[3];
const format = match[4];
const apiUrl = `${githubApiUrl}/repos/${user}/${repo}/stats/commit_activity`;
const badgeData = getBadgeData('commit activity', data);
if (badgeData.template === 'social') {
badgeData.logo = getLogo('github', data);
badgeData.links = [`https://github.com/${user}/${repo}`];
}
githubAuth.request(request, apiUrl, {}, function(err, res, buffer) {
if (err !== null) {
badgeData.text[1] = 'inaccessible';
sendBadge(format, badgeData);
return;
}
try {
const parsedData = JSON.parse(buffer);
let value;
let intervalLabel;
switch (interval) {
case 'y':
value = parsedData.reduce((sum, weekInfo) => sum + weekInfo.total, 0);
intervalLabel = '/year';
break;
case '4w':
value = parsedData.slice(-4).reduce((sum, weekInfo) => sum + weekInfo.total, 0);
intervalLabel = '/4 weeks';
break;
case 'w':
value = parsedData.slice(-1)[0].total;
intervalLabel = '/week';
break;
default:
throw Error('Unhandled case');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

throw new Error

}
badgeData.text[1] = `${metric(value)}${intervalLabel}`;
badgeData.colorscheme = 'blue';
sendBadge(format, badgeData);
} catch(e) {
badgeData.text[1] = 'invalid';
sendBadge(format, badgeData);
}
});
}));

// GitHub last commit integration.
camp.route(/^\/github\/last-commit\/([^\/]+)\/([^\/]+)(?:\/(.+))?\.(svg|png|gif|jpg|json)$/,
cache(function(data, match, sendBadge, request) {
const user = match[1]; // eg, mashape
const repo = match[2]; // eg, apistatus
const branch = match[3];
const format = match[4];
let apiUrl = `${githubApiUrl}/repos/${user}/${repo}/commits`;
if (branch) {
apiUrl += `?sha=${branch}`;
}
const badgeData = getBadgeData('last commit', data);
if (badgeData.template === 'social') {
badgeData.logo = getLogo('github', data);
badgeData.links = [`https://github.com/${user}/${repo}`];
}
githubAuth.request(request, apiUrl, {}, function(err, res, buffer) {
if (err !== null) {
badgeData.text[1] = 'inaccessible';
sendBadge(format, badgeData);
return;
}
try {
const parsedData = JSON.parse(buffer);
const commitDate = parsedData[0].commit.author.date;
badgeData.text[1] = formatDate(commitDate);
badgeData.colorscheme = ageColor(Date.parse(commitDate));
sendBadge(format, badgeData);
} catch(e) {
badgeData.text[1] = 'invalid';
sendBadge(format, badgeData);
}
});
}));

// Bitbucket issues integration.
camp.route(/^\/bitbucket\/issues(-raw)?\/([^\/]+)\/([^\/]+)\.(svg|png|gif|jpg|json)$/,
cache(function(data, match, sendBadge, request) {
Expand Down
42 changes: 42 additions & 0 deletions service-tests/github.js
Original file line number Diff line number Diff line change
Expand Up @@ -311,3 +311,45 @@ t.create('hit counter for nonexistent repo')
name: Joi.equal('goto counter'),
value: Joi.string().regex(/^repo not found$/),
}));

t.create('commit activity (1 year)')
.get('/commit-activity/y/eslint/eslint.json')
.expectJSONTypes(Joi.object().keys({
name: Joi.equal('commit activity'),
value: Joi.string().regex(/^[0-9]+[kMGTPEZY]?\/year$/),
}));

t.create('commit activity (4 weeks)')
.get('/commit-activity/4w/eslint/eslint.json')
.expectJSONTypes(Joi.object().keys({
name: Joi.equal('commit activity'),
value: Joi.string().regex(/^[0-9]+[kMGTPEZY]?\/4 weeks$/),
}));

t.create('commit activity (1 week)')
.get('/commit-activity/w/eslint/eslint.json')
.expectJSONTypes(Joi.object().keys({
name: Joi.equal('commit activity'),
value: Joi.string().regex(/^[0-9]+[kMGTPEZY]?\/week$/),
}));

t.create('last commit (recent)')
.get('/last-commit/eslint/eslint.json')
.expectJSONTypes(Joi.object().keys({
name: Joi.equal('last commit'),
value: Joi.string().regex(/^today|yesterday|last (?:sun|mon|tues|wednes|thurs|fri|satur)day/),
}));

t.create('last commit (ancient)')
.get('/last-commit/badges/badgr.co.json')
.expectJSONTypes(Joi.object().keys({
name: Joi.equal('last commit'),
value: Joi.equal('january 2014'),
}));

t.create('last commit (on branch)')
.get('/last-commit/badges/badgr.co/shielded.json')
.expectJSONTypes(Joi.object().keys({
name: Joi.equal('last commit'),
value: Joi.equal('july 2013'),
}));
11 changes: 11 additions & 0 deletions try.html
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,17 @@ <h3 id="miscellaneous"> Miscellaneous </h3>
<tr><th data-keywords='GitHub search hit counter' data-doc='githubDoc'> Github search hit counter: </th>
<td><img src='/github/search/torvalds/linux/goto.svg' alt=''/></td>
<td><code>https://img.shields.io/github/search/torvalds/linux/goto.svg</code></td>
<tr><th data-keywords='GitHub commit commits activity' data-doc='githubDoc'> GitHub commit activity the past week, 4 weeks, year </th>
<td><img src='/github/commit-activity/y/eslint/eslint.svg' alt=''/></td>
<td><code>https://img.shields.io/github/commit-activity/y/eslint/eslint.svg</code></td>
</tr>
<tr><th data-keywords='GitHub last latest commit' data-doc='githubDoc'> GitHub last commit: </th>
<td><img src='/github/last-commit/google/skia.svg' alt=''/></td>
<td><code>https://img.shields.io/github/commits/google/skia/last.svg</code></td>
</tr>
<tr><th data-keywords='GitHub last latest commit' data-doc='githubDoc'> GitHub last commit (branch): </th>
<td><img src='/github/last-commit/google/skia/infra/config.svg' alt=''/></td>
<td><code>https://img.shields.io/github/commits/google/skia/infra/config/last.svg</code></td>
</tr>
<tr><th> Bitbucket issues: </th>
<td><img src='/bitbucket/issues/atlassian/python-bitbucket.svg' alt=''/></td>
Expand Down