diff --git a/services/github/github-commit-activity.service.js b/services/github/github-commit-activity.service.js index ee7ad583ac849bd239a26f1cc7013d6b00a0c3a6..821827a77d051e013a257ef320c3c15fea877d94 100644 --- a/services/github/github-commit-activity.service.js +++ b/services/github/github-commit-activity.service.js @@ -1,22 +1,28 @@ +import gql from 'graphql-tag' import Joi from 'joi' +import { InvalidResponse } from '../index.js' import { metric } from '../text-formatters.js' import { nonNegativeInteger } from '../validators.js' -import { GithubAuthV3Service } from './github-auth-service.js' -import { errorMessagesFor, documentation } from './github-helpers.js' +import { GithubAuthV4Service } from './github-auth-service.js' +import { transformErrors, documentation } from './github-helpers.js' -const schema = Joi.array() - .items( - Joi.object({ - total: nonNegativeInteger, - }) - ) - .required() +const schema = Joi.object({ + data: Joi.object({ + repository: Joi.object({ + object: Joi.object({ + history: Joi.object({ + totalCount: nonNegativeInteger, + }).required(), + }), + }).required(), + }).required(), +}).required() -export default class GithubCommitActivity extends GithubAuthV3Service { +export default class GitHubCommitActivity extends GithubAuthV4Service { static category = 'activity' static route = { base: 'github/commit-activity', - pattern: ':interval(y|m|4w|w)/:user/:repo', + pattern: ':interval(y|m|4w|w)/:user/:repo/:branch*', } static examples = [ @@ -29,6 +35,20 @@ export default class GithubCommitActivity extends GithubAuthV3Service { keywords: ['commits'], documentation, }, + { + title: 'GitHub commit activity (branch)', + // Override the pattern to omit the deprecated interval "4w". + pattern: ':interval(y|m|w)/:user/:repo/:branch*', + namedParams: { + interval: 'm', + user: 'badges', + repo: 'squint', + branch: 'main', + }, + staticPreview: this.render({ interval: 'm', commitCount: 5 }), + keywords: ['commits'], + documentation, + }, ] static defaultBadgeData = { label: 'commit activity', color: 'blue' } @@ -46,47 +66,67 @@ export default class GithubCommitActivity extends GithubAuthV3Service { } } - static transform({ interval, weekData }) { - const weekTotals = weekData.map(({ total }) => total) + async fetch({ interval, user, repo, branch = 'HEAD' }) { + const since = this.constructor.getIntervalQueryStartDate({ interval }) + return this._requestGraphql({ + query: gql` + query ( + $user: String! + $repo: String! + $branch: String! + $since: GitTimestamp! + ) { + repository(owner: $user, name: $repo) { + object(expression: $branch) { + ... on Commit { + history(since: $since) { + totalCount + } + } + } + } + } + `, + variables: { + user, + repo, + branch, + since, + }, + schema, + transformErrors, + }) + } + + static transform({ data }) { + const { + repository: { object: repo }, + } = data - if (interval === 'm') { - // To approximate the value for the past month, get the sum for the last - // four weeks and add a weighted value for the fifth week. - const fourWeeksValue = weekTotals - .slice(-4) - .reduce((sum, weekTotal) => sum + weekTotal, 0) - const fifthWeekValue = weekTotals.slice(-5)[0] - const averageWeeksPerMonth = 365 / 12 / 7 - return ( - fourWeeksValue + Math.round((averageWeeksPerMonth - 4) * fifthWeekValue) - ) + if (!repo) { + throw new InvalidResponse({ prettyMessage: 'invalid branch' }) } - let wantedWeekData - switch (interval) { - case 'y': - wantedWeekData = weekTotals - break - case '4w': - wantedWeekData = weekTotals.slice(-4) - break - case 'w': - wantedWeekData = weekTotals.slice(-2, -1) - break - default: - throw Error('Unhandled case') + return repo.history.totalCount + } + + static getIntervalQueryStartDate({ interval }) { + const now = new Date() + + if (interval === 'y') { + now.setUTCFullYear(now.getUTCFullYear() - 1) + } else if (interval === 'm' || interval === '4w') { + now.setUTCDate(now.getUTCDate() - 30) + } else { + now.setUTCDate(now.getUTCDate() - 7) } - return wantedWeekData.reduce((sum, weekTotal) => sum + weekTotal, 0) + return now.toISOString() } - async handle({ interval, user, repo }) { - const weekData = await this._requestJson({ - url: `/repos/${user}/${repo}/stats/commit_activity`, - schema, - errorMessages: errorMessagesFor(), - }) - const commitCount = this.constructor.transform({ interval, weekData }) + async handle({ interval, user, repo, branch }) { + const json = await this.fetch({ interval, user, repo, branch }) + const commitCount = this.constructor.transform(json) return this.constructor.render({ interval, commitCount }) } } diff --git a/services/github/github-commit-activity.spec.js b/services/github/github-commit-activity.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..1815bf2dd90c1146025c4573abcee0f7de9b0de8 --- /dev/null +++ b/services/github/github-commit-activity.spec.js @@ -0,0 +1,64 @@ +import { expect } from 'chai' +import sinon from 'sinon' +import { InvalidResponse } from '../index.js' +import GitHubCommitActivity from './github-commit-activity.service.js' + +describe('GitHubCommitActivity', function () { + describe('transform', function () { + it('throws InvalidResponse on invalid branch and null object', function () { + expect(() => + GitHubCommitActivity.transform({ + data: { repository: { object: null } }, + }) + ) + .to.throw(InvalidResponse) + .with.property('prettyMessage', 'invalid branch') + }) + }) + describe('getIntervalQueryStartDate', function () { + /** @type {sinon.SinonFakeTimers} */ + let clock + beforeEach(function () { + clock = sinon.useFakeTimers() + }) + afterEach(function () { + clock.restore() + }) + + it('provides correct value for yearly interval', function () { + clock.tick(new Date('2021-08-28T02:21:34.000Z').getTime()) + expect( + GitHubCommitActivity.getIntervalQueryStartDate({ + interval: 'y', + }) + ).to.equal('2020-08-28T02:21:34.000Z') + }) + + it('provides correct value for simple monthly interval', function () { + clock.tick(new Date('2021-03-31T02:21:34.000Z').getTime()) + expect( + GitHubCommitActivity.getIntervalQueryStartDate({ + interval: 'm', + }) + ).to.equal('2021-03-01T02:21:34.000Z') + }) + + it('provides correct value for fun monthly interval', function () { + clock.tick(new Date('2021-03-07T02:21:34.000Z').getTime()) + expect( + GitHubCommitActivity.getIntervalQueryStartDate({ + interval: '4w', + }) + ).to.equal('2021-02-05T02:21:34.000Z') + }) + + it('provides correct value for weekly interval', function () { + clock.tick(new Date('2021-12-31T23:59:34.000Z').getTime()) + expect( + GitHubCommitActivity.getIntervalQueryStartDate({ + interval: 'w', + }) + ).to.equal('2021-12-24T23:59:34.000Z') + }) + }) +}) diff --git a/services/github/github-commit-activity.tester.js b/services/github/github-commit-activity.tester.js index bd18f2d68821ea44a69d65378caae9f73668656c..b109d5b8102bac3452438bd0ecd9db2aab49ef65 100644 --- a/services/github/github-commit-activity.tester.js +++ b/services/github/github-commit-activity.tester.js @@ -33,6 +33,13 @@ t.create('commit activity (1 week)').get('/w/eslint/eslint.json').expectBadge({ message: isCommitActivity, }) +t.create('commit activity (custom branch)') + .get('/y/badges/squint/main.json') + .expectBadge({ + label: 'commit activity', + message: isCommitActivity, + }) + t.create('commit activity (repo not found)') .get('/w/badges/helmets.json') .expectBadge({