From ca9516ce7fff3bca18ce875a0114b9735ca6a142 Mon Sep 17 00:00:00 2001 From: Joyee Cheung Date: Mon, 23 Oct 2017 14:05:52 +0800 Subject: [PATCH] ncu-contrib: initial implementation --- bin/ncu-contrib | 79 +++++++++++ lib/contribution-analyzer.js | 254 +++++++++++++++++++++++++++++++++++ lib/queries/SearchCommit.gql | 26 ++++ lib/queries/SearchIssue.gql | 13 +- lib/queries/User.gql | 7 + lib/request.js | 1 + package.json | 3 +- 7 files changed, 378 insertions(+), 5 deletions(-) create mode 100755 bin/ncu-contrib create mode 100644 lib/contribution-analyzer.js create mode 100644 lib/queries/SearchCommit.gql create mode 100644 lib/queries/User.gql diff --git a/bin/ncu-contrib b/bin/ncu-contrib new file mode 100755 index 00000000..5b0d5e08 --- /dev/null +++ b/bin/ncu-contrib @@ -0,0 +1,79 @@ +#!/usr/bin/env node + +'use strict'; + +const SEARCH_ISSUE = 'SearchIssue'; + +const Request = require('../lib/request'); +const auth = require('../lib/auth'); +const { runPromise } = require('../lib/run'); +const CLI = require('../lib/cli'); +const ContributionAnalyzer = require('../lib/contribution-analyzer'); + +const yargs = require('yargs'); +const argv = yargs + .command({ + command: 'collaborators', + desc: 'Getting contributions from collaborators', + handler: handler + }) + .command({ + command: 'tsc', + desc: 'Getting contributions from TSC members', + handler: handler + }) + .command({ + command: 'for ', + desc: 'Getting contributions from GitHub handles', + builder: (yargs) => { + yargs + .positional('ids', { + describe: 'GitHub handles', + type: 'array' + }); + }, + handler: handler + }) + .string('repo') + .string('owner') + .string('type') + .string('branch') + .string('readme') + .default({ + repo: 'node', + owner: 'nodejs', + type: 'participation', + branch: 'master' + }) + .demandCommand(1, 'must provide a valid command') + .help() + .argv; + +async function main(argv) { + const cli = new CLI(); + const credentials = await auth(); + const request = new Request(credentials); + const analyzer = new ContributionAnalyzer(request, cli, argv); + + const [ command ] = argv._; + switch (command) { + case 'collaborators': + const collaborators = await analyzer.getCollaborators(); + await analyzer.getLatestContributionForIds( + collaborators.map(user => user.login)); + break; + case 'tsc': + const tsc = await analyzer.getTSC(); + await analyzer.getLatestContributionForIds(tsc.map(user => user.login)); + break; + case 'for': + await analyzer.getLatestContributionForIds(argv.ids); + break; + default: + throw new Error(`Unknown command ${command}`); + } +} + +function handler(argv) { + runPromise(main(argv)); +} diff --git a/lib/contribution-analyzer.js b/lib/contribution-analyzer.js new file mode 100644 index 00000000..f3e1f75c --- /dev/null +++ b/lib/contribution-analyzer.js @@ -0,0 +1,254 @@ +'use strict'; + +const SEARCH_ISSUE = 'SearchIssue'; +const SEARCH_COMMIT = 'SearchCommit'; +const USER = 'User'; + +const fs = require('fs'); +const path = require('path'); +const { writeJson, readJson, readFile } = require('./file'); +const { getCollaborators } = require('./collaborators'); +const { isTheSamePerson } = require('./user'); + +class ContributionAnalyzer { + constructor(request, cli, argv) { + this.request = request; + this.cli = cli; + this.argv = argv; + const cacheDir = path.resolve(__dirname, '..', '.ncu', + 'cache'); + const type = this.argv.type; + this.cache = { + temp: path.join(cacheDir, `${type}.json`), + result: path.join(cacheDir, `${type}-sorted.json`) + }; + } + + participationByUser(issue, user) { + const result = { + url: issue.url, + date: new Date(issue.publishedAt).toISOString(), + isRelavent: false, + type: '' + }; + + // Author + if (isTheSamePerson(issue.author, user)) { + result.date = issue.publishedAt; + result.isRelavent = true; + result.type = /pull/.test(issue.url) ? 'pull' : 'issue'; + } + + if (issue.reviews) { + issue.reviews.nodes.forEach((review) => { + if (!isTheSamePerson(review.author, user)) { + return; + } + + result.isRelavent = true; + if (review.publishedAt > result.date) { + result.date = review.publishedAt; + result.type = 'review'; + } + }); + } + + issue.comments.nodes.forEach((comment) => { + if (!isTheSamePerson(comment.author, user)) { + return; + } + + result.isRelavent = true; + if (comment.publishedAt > result.date) { + result.date = comment.publishedAt; + result.type = 'comment'; + } + }); + + return result; + } + + async getParticipation(user) { + const { cli, request } = this; + const results = await request.gql(SEARCH_ISSUE, { + queryString: `involves:${user} org:nodejs`, + mustBeAuthor: false + }); + if (results.search.nodes.length === 0) { + return [{ + url: 'N/A', + date: 'N/A', + isRelavent: false, + type: 'N/A' + }]; + } + + const res = results.search.nodes + .map(issue => this.participationByUser(issue, user)) + // .filter((res) => res.isRelavent) + .sort((a, b) => { + return a.date > b.date ? -1 : 1; + }); + return res; + } + + async getCommits(user) { + const { cli, request, argv } = this; + const { owner, repo, branch } = argv; + + const userData = await request.gql(USER, { login: user }); + const authorId = userData.user.id; + const results = await request.gql(SEARCH_COMMIT, { + owner, repo, branch, authorId + }, [ 'repository', 'ref', 'target', 'history' ]); + return results + .sort((a, b) => { + return a.authoredDate > b.authoredDate ? -1 : 1; + }); + } + + async getContributionsForId(user) { + const { argv } = this; + if (argv.type === 'participation') { + return this.getParticipation(user); + } else if (argv.type === 'commit') { + return this.getCommits(user); + } + } + + async getLatestContributionForId(user) { + const contributions = await this.getContributionsForId(user); + return contributions[0] || { + url: 'N/A', + date: 'N/A', + isRelavent: false, + type: 'N/A' + }; + } + + async getCollaborators() { + const { cli, request, argv } = this; + const { owner, repo, readme } = argv; + cli.startSpinner('Getting collaborator contacts'); + let readmeText; + if (readme) { + cli.updateSpinner(`Reading collaborator contacts from ${readme}`); + readmeText = readFile(readme); + } else { + cli.updateSpinner( + `Getting collaborator contacts from README of ${owner}/${repo}`); + const url = `https://raw.githubusercontent.com/${owner}/${repo}/master/README.md`; + readmeText = await request.text(url); + } + const collaborators = getCollaborators(readmeText, cli, owner, repo); + const result = Array.from(collaborators.values()); + cli.stopSpinner(`Read ${result.length} collaborators`); + return result; + } + + async getTSC() { + const collaborators = await this.getCollaborators(); + return collaborators.filter((user) => user.isTSC()); + } + + formatContribution(user, data) { + if (this.argv.type === 'participation') { + const type = + data.type.padEnd(8).toUpperCase(); + const date = data.date.slice(0, 10); + return `${date} ${type} @${user.padEnd(22)} ${data.url}`; + } else if (this.argv.type === 'commit') { + const hash = data.oid.slice(0, 7); + const date = data.authoredDate.slice(0, 10); + const message = data.messageHeadline; + return `${date} ${hash} @${user.padEnd(22)} ${message}`; + } + } + + getResult(user, data) { + if (this.argv.type === 'participation') { + return { + user, + date: data.date, + url: data.url, + type: data.type + }; + } else if (this.argv.type === 'commit') { + return { + user, + authoredDate: data.authoredDate, + messageHeadline: data.messageHeadline, + oid: data.oid + }; + } + } + + getDate(item) { + if (this.argv.type === 'participation') { + return item.date; + } else if (this.argv.type === 'commit') { + return item.authoredDate; + } + } + async getResults(ids) { + const { cache, cli, argv, request } = this; + + let latestContrib = cache ? readJson(cache.temp) : {}; + const total = ids.length; + let counter = 1; + + for (const user of ids) { + cli.startSpinner(`Grabbing data for @${user}, ${counter++}/${total}..`); + let data = latestContrib[user]; + if (data) { + cli.updateSpinner(`Skip grabbing cached data for @${user}`); + } else { + data = await this.getLatestContributionForId(user); + latestContrib[user] = data; + if (cache && counter % 11 === 10) { + writeJson(cache.temp, latestContrib); + } + } + + cli.stopSpinner(this.formatContribution(user, data)); + } + + if (cache) { + writeJson(cache.temp, latestContrib); + } + + const sorted = ids.sort((a, b) => { + const aa = latestContrib[a]; + const bb = latestContrib[b]; + return this.getDate(aa) < this.getDate(bb) ? -1 : 1; + }).map((user) => { + const data = latestContrib[user]; + return this.getResult(user, data); + }); + return sorted; + } + + async getLatestContributionForIds(ids) { + const { cache, cli, argv, request } = this; + let sorted; + + if (this.cache && fs.existsSync(this.cache.result)) { + sorted = readJson(this.cache.result); + const existingIds = new Set(sorted.map(item => item.user)); + if (ids.every(id => existingIds.has(id))) { + for (const data of sorted) { + cli.log(this.formatContribution(data.user, data)); + } + return; + } + } + + sorted = await this.getResults(ids); + + if (cache) { + writeJson(cache.result, sorted); + } + } +} + +module.exports = ContributionAnalyzer; diff --git a/lib/queries/SearchCommit.gql b/lib/queries/SearchCommit.gql new file mode 100644 index 00000000..bbcd091a --- /dev/null +++ b/lib/queries/SearchCommit.gql @@ -0,0 +1,26 @@ +query SearchCommitByAuthor($repo: String!, $owner: String!, $branch: String!, $authorId: ID!, $after: String) { + repository(name: $repo, owner: $owner) { + ref(qualifiedName: $branch) { + target { + ... on Commit { + history(first: 100, after: $after, author: { id: $authorId } ) { + totalCount + pageInfo { hasNextPage, endCursor } + nodes { + oid + authoredDate + messageHeadline + author { + email + name + user { + login + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/lib/queries/SearchIssue.gql b/lib/queries/SearchIssue.gql index 78c26335..fe6edfbd 100644 --- a/lib/queries/SearchIssue.gql +++ b/lib/queries/SearchIssue.gql @@ -1,5 +1,10 @@ -query SearchIssueByUser($queryString: String!, $isCommenter: Boolean!, $after: String) { +query SearchIssueByUser($queryString: String!, $mustBeAuthor: Boolean!, $after: String) { search(query: $queryString, type: ISSUE, first: 100, after: $after) { + issueCount + pageInfo { + hasNextPage + endCursor + } nodes { ... on PullRequest { url @@ -8,7 +13,7 @@ query SearchIssueByUser($queryString: String!, $isCommenter: Boolean!, $after: S login } title - reviews(last: 100) @include(if: $isCommenter) { + reviews(last: 100) @skip(if: $mustBeAuthor) { nodes { publishedAt author { @@ -21,7 +26,7 @@ query SearchIssueByUser($queryString: String!, $isCommenter: Boolean!, $after: S name } } - comments(last: 100) @include(if: $isCommenter) { + comments(last: 100) @skip(if: $mustBeAuthor) { nodes { publishedAt author { @@ -37,7 +42,7 @@ query SearchIssueByUser($queryString: String!, $isCommenter: Boolean!, $after: S login } title - comments(last: 100) @include(if: $isCommenter) { + comments(last: 100) @skip(if: $mustBeAuthor) { nodes { publishedAt author { diff --git a/lib/queries/User.gql b/lib/queries/User.gql new file mode 100644 index 00000000..61f83bc8 --- /dev/null +++ b/lib/queries/User.gql @@ -0,0 +1,7 @@ +query User($login: String!) { + user(login: $login) { + login, + email, + id + } +} \ No newline at end of file diff --git a/lib/request.js b/lib/request.js index 8b4d68f0..2ee93205 100644 --- a/lib/request.js +++ b/lib/request.js @@ -9,6 +9,7 @@ class Request { this.credentials = credentials; } + // TODO: cache this loadQuery(file) { const filePath = path.resolve(__dirname, 'queries', `${file}.gql`); return fs.readFileSync(filePath, 'utf8'); diff --git a/package.json b/package.json index cd01f1c8..1812c5ec 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "get-metadata": "./bin/get-metadata", "ncu-config": "./bin/ncu-config", "git-node": "./bin/git-node", - "ncu-team": "./bin/ncu-team" + "ncu-team": "./bin/ncu-team", + "ncu-contrib": "./bin/ncu-contrib" }, "scripts": { "test": "npm run test-unit && npm run lint",