Compare commits

..

No commits in common. "master" and "v2.1.3" have entirely different histories.

17 changed files with 1360 additions and 6998 deletions

View File

@ -1,2 +0,0 @@
GITEA_TOKEN=your-token-here
GITHUB_TOKEN=your-token-here

View File

@ -1,4 +0,0 @@
template: |
## Changes
$CHANGES

1
.gitignore vendored
View File

@ -1,3 +1,2 @@
node_modules node_modules
wallet.json wallet.json
.env

102
README.md
View File

@ -1,42 +1,17 @@
[![npm](https://img.shields.io/npm/v/@kredits/hubot-kredits.svg)](https://www.npmjs.com/package/@kredits/hubot-kredits) [![npm](https://img.shields.io/npm/v/hubot-kredits.svg)](https://www.npmjs.com/package/hubot-kredits)
# Hubot Kredits # Hubot Kredits
This repository provides scripts for integrating [Kosmos This repository provides scripts for integrating [Kosmos
Kredits](https://wiki.kosmos.org/Kredits) in [Hubot](http://hubot.github.com/) Kredits](https://wiki.kosmos.org/Kredits) in [Hubot](http://hubot.github.com/)
chatbots. The bot will watch for project-related things happening on the chatbots. The bot will watch for project-related things happening on the
Internet and automatically create ERC721 tokens for issuing kredits for project Internet and automatically create proposals for issuing kredits for project
contributions. contributions.
## Setup ## Setup
### Wallet You will need an Ethereum wallet for your bot, so it can interact with the
Ethereum smart contracts. `npm run create-wallet` will do the job for you.
You will need a keypair/wallet for your bot, so it can interact with the smart
contracts. `npm run create-wallet` will do the job for you.
The wallet must be funded with enough native chain tokens to interact with the
contracts (i.e. it must be able to pay gas/tx fees)
### Contract permissions
**Warning: outdated instructions!**
*TODO adapt instructions for new permission model*
The bot wallet needs the following Aragon contract permissions to interact
with [kredits-contracts]:
1. `ADD_CONTRIBUTION_ROLE` on the `Contribution` contract
2. `MANAGE_CONTRIBUTORS_ROLE` on the `Contributor` contract
These permissions can be configured using the [Aragon
CLI](https://hack.aragon.org/docs/cli-intro.html) (see [kredits-contracts].
aragon dao acl grant [DAO address] [contribution app address] ADD_CONTRIBUTION_ROLE [bot wallet address]
aragon dao acl grant [DAO address] [contributor app address] MANAGE_CONTRIBUTORS_ROLE [bot wallet address]
To get the `Contribution` and `Contributor` app addresses use `aragon dao apps`.
## Configuration ## Configuration
@ -46,13 +21,10 @@ As usual in Hubot, you can add all config as environment variables.
| --- | --- | | --- | --- |
| `KREDITS_WEBHOOK_TOKEN` | A string for building your secret webhook URLs | | `KREDITS_WEBHOOK_TOKEN` | A string for building your secret webhook URLs |
| `KREDITS_ROOM` | The bot will talk to you in this room | | `KREDITS_ROOM` | The bot will talk to you in this room |
| `KREDITS_WALLET_PATH` | Path to an wallet JSON file (default: `./wallet.json`) | | `KREDITS_WALLET_PATH` | Path to an Etherum wallet JSON file (default: `./wallet.json`) |
| `KREDITS_WALLET_PASSWORD` | Wallet password | | `KREDITS_WALLET_PASSWORD` | Wallet password |
| `KREDITS_PROVIDER_URL` | JSON-RPC URL of a blockchain node (default: `http://localhost:7545`) | | `KREDITS_PROVIDER_URL` | Ethereum JSON-RPC URL (default: `http://localhost:7545`) |
| `KREDITS_WEB_URL` | URL of the Kredits Web app (default: `https://kredits.kosmos.org`) | | `KREDITS_NETWORK_ID` | The ethereum network ID to use (default: 100 = local) |
| `KREDITS_SESSION_SECRET` | Secret used by [grant](https://www.npmjs.com/package/grant) to sign the session ID |
| `KREDITS_GRANT_HOST` | Host used by [grant](https://www.npmjs.com/package/grant) to generate OAuth redirect URLs (default: `localhost:8888`) |
| `KREDITS_GRANT_PROTOCOL` | Protocol (http or https) used by [grant](https://www.npmjs.com/package/grant") to generate the OAuth redirect URLs (default: "http") |
## Integrations ## Integrations
@ -61,13 +33,7 @@ As usual in Hubot, you can add all config as environment variables.
The GitHub integration will watch for closed issues and merged pull requests, The GitHub integration will watch for closed issues and merged pull requests,
which carry a kredits label: `kredits-1`, `kredits-2`, `kredits-3` for small, which carry a kredits label: `kredits-1`, `kredits-2`, `kredits-3` for small,
medium and large contributions. If there are multiple people assigned, it will medium and large contributions. If there are multiple people assigned, it will
issue contribution tokens for all of them. issue proposals for all of them.
If `KREDITS_GITHUB_KEY` and `KREDITS_GITHUB_SECRET` are set, the bot will also
expose OAuth endpoints to authenticate new contributors and register new
contributor profiles on the smart contract. For this feature, a [GitHub OAuth
app] is required and the [OAuth grant config variables](#Configuration) must be
set.
#### Setup #### Setup
@ -80,32 +46,11 @@ Point a GitHub organization webhook to the following URL:
| Key | Description | | Key | Description |
| --- | --- | | --- | --- |
| `KREDITS_GITHUB_REPO_BLACKLIST` | Repos which you do not want to issue kredits for. Format: `orgname/reponame`, e.g. `67P/test-one-two` | | `KREDITS_GITHUB_REPO_BLACKLIST` | Repos which you do not want to issue kredits for. Format: `orgname/reponame`, e.g. `67P/test-one-two` |
| `KREDITS_GITHUB_KEY` | Key of the [GitHub OAuth app] used to authenticate contributors |
| `KREDITS_GITHUB_SECRET` | Secret of the [GitHub OAuth app] used to authenticate contributors |
### Gitea
The Gitea integration will watch for closed issues and merged pull requests,
which carry a kredits label: `kredits-1`, `kredits-2`, `kredits-3` for small,
medium and large contributions. If there are multiple people assigned, it will
issue contribution tokens for all of them.
#### Setup
Point a Gitea organization webhook to the following URL:
https://your-hubot.example.com/incoming/kredits/gitea/{webhook_token}
#### Config
| Key | Description |
| --- | --- |
| `KREDITS_GITEA_REPO_BLACKLIST` | Repos which you do not want to issue kredits for. Format: `orgname/reponame`, e.g. `kosmos/test-one-two` |
### MediaWiki ### MediaWiki
The MediaWiki integration will periodically check for wiki page creations and The MediaWiki integration will periodically check for wiki page creations and
edits. It will create kredits contribution tokens based on amount of text added. edits. It will create kredits proposals based on amount of text added.
#### Setup #### Setup
@ -117,32 +62,3 @@ wiki's API on its own.
| Key | Description | | Key | Description |
| --- | --- | | --- | --- |
| `KREDITS_MEDIAWIKI_URL` | Your wiki URL, e.g. `https://wiki.kosmos.org/` | | `KREDITS_MEDIAWIKI_URL` | Your wiki URL, e.g. `https://wiki.kosmos.org/` |
[kredits-contracts]: https://github.com/67P/kredits-contracts
[GitHub OAuth app]: https://developer.github.com/apps/about-apps/#about-oauth-apps
### Zoom
The Zoom integration creates contributions for meeting participations.
Every meeting that is longer than 15 minutes and with more than 2 participants will be registered.
An optional meeting whitelist can be configured to create contributions only for specific meetings.
#### Setup
A Zoom JWT app has to be set up and an [event webhook subscription](https://marketplace.zoom.us/docs/api-reference/webhook-reference/meeting-events/meeting-ending")
on `meeting.ended` has to be configured to the following URL:
https://your-hubot.example.com/incoming/kredits/zoom/{webhook_token}
#### Config
| Key | Description |
| --- | --- |
| `KREDITS_ZOOM_JWT` | The JWT for the Zoom application (required)
| `KREDITS_ZOOM_MEETING_WHITELIST` | Comma separated list of meeting names for which kredits should be tracked (optional)
| `KREDITS_ZOOM_CONTRIBUTION_AMOUNT` | The amount of kredits issued for each meeting. (default: 500)
[Zoom apps](https://marketplace.zoom.us/user/build)

View File

@ -1,11 +1,13 @@
const fs = require('fs'); const fs = require('fs');
const util = require('util');
const fetch = require('node-fetch');
const ethers = require('ethers'); const ethers = require('ethers');
const NonceManager = require('@ethersproject/experimental').NonceManager; const Kredits = require('kredits-contracts');
const Kredits = require('@kredits/contracts');
const walletPath = process.env.KREDITS_WALLET_PATH || './wallet.json'; const walletPath = process.env.KREDITS_WALLET_PATH || './wallet.json';
const walletJson = fs.readFileSync(walletPath); const walletJson = fs.readFileSync(walletPath);
const providerUrl = process.env.KREDITS_PROVIDER_URL || 'http://localhost:7545'; const providerUrl = process.env.KREDITS_PROVIDER_URL || 'http://localhost:7545';
const networkId = parseInt(process.env.KREDITS_NETWORK_ID || 100);
const ipfsConfig = { const ipfsConfig = {
host: process.env.IPFS_API_HOST || 'localhost', host: process.env.IPFS_API_HOST || 'localhost',
@ -25,9 +27,9 @@ module.exports = async function(robot) {
let wallet; let wallet;
try { try {
wallet = await ethers.Wallet.fromEncryptedJson(walletJson, process.env.KREDITS_WALLET_PASSWORD); wallet = await ethers.Wallet.fromEncryptedWallet(walletJson, process.env.KREDITS_WALLET_PASSWORD);
} catch(error) { } catch(error) {
robot.logger.warning('[hubot-kredits] Could not load wallet:', error); robot.logger.warn('[hubot-kredits] Could not load wallet:', error);
process.exit(1); process.exit(1);
} }
@ -35,27 +37,23 @@ module.exports = async function(robot) {
// Ethereum provider/node setup // Ethereum provider/node setup
// //
robot.logger.info('[hubot-kredits] Using blockchain node/API at', providerUrl); const ethProvider = new ethers.providers.JsonRpcProvider(providerUrl, {chainId: networkId});
ethProvider.signer = wallet;
const ethProvider = new ethers.providers.JsonRpcProvider(providerUrl); wallet.provider = ethProvider;
const signer = new NonceManager(wallet.connect(ethProvider));
// //
// Kredits contracts setup // Kredits contracts setup
// //
const opts = { ipfsConfig };
let kredits; let kredits;
try { try {
kredits = await new Kredits(signer.provider, signer, opts).init(); kredits = await Kredits.setup(ethProvider, wallet, ipfsConfig);
} catch(error) { } catch(error) {
robot.logger.warning('[hubot-kredits] Could not set up kredits:', error); robot.logger.warn('[hubot-kredits] Could not set up kredits:', error);
process.exit(1); process.exit(1);
} }
const Contributor = kredits.Contributor; const Contributor = kredits.Contributor;
const Contribution = kredits.Contribution; const Operator = kredits.Operator;
// TODO const Reimbursement = kredits.Reimbursement;
robot.logger.info('[hubot-kredits] Wallet address: ' + wallet.address); robot.logger.info('[hubot-kredits] Wallet address: ' + wallet.address);
@ -64,9 +62,9 @@ module.exports = async function(robot) {
// //
ethProvider.getBalance(wallet.address).then(balance => { ethProvider.getBalance(wallet.address).then(balance => {
robot.logger.info('[hubot-kredits] Wallet balance: ' + ethers.utils.formatEther(balance) + ' RBTC'); robot.logger.info('[hubot-kredits] Wallet balance: ' + ethers.utils.formatEther(balance) + 'ETH');
if (balance.lt(ethers.utils.parseEther('0.0001'))) { if (balance.lt(ethers.utils.parseEther('0.0001'))) {
messageRoom(`Yo gang, I\'m broke! Please send some RBTC to ${wallet.address}. kthxbai.`); messageRoom(`Yo gang, I\'m broke! Please drop me some ETH to ${wallet.address}. kthxbai.`);
} }
}); });
@ -74,9 +72,30 @@ module.exports = async function(robot) {
// Robot chat commands/interaction // Robot chat commands/interaction
// //
robot.respond(/got RBTC\??/i, res => { robot.respond(/got ETH\??/i, res => {
ethProvider.getBalance(wallet.address).then((balance) => { ethProvider.getBalance(wallet.address).then((balance) => {
res.send(`My wallet contains ${ethers.utils.formatEther(balance)} RBTC`); res.send(`my wallet contains ${ethers.utils.formatEther(balance)} ETH`);
});
});
robot.respond(/propose (\d*)\s?\S*\s?to (\S+)(?:\sfor (.*))?$/i, res => {
let [_, amount, githubUser, description] = res.match;
let url = null;
createProposal(githubUser, amount, description, url).then((result) => {
messageRoom('Sounds good! Will be listed on https://kredits.kosmos.org in a bit...');
});
});
robot.respond(/list open proposals/i, res => {
Operator.all().then((proposals) => {
proposals.forEach((proposal) => {
if (!proposal.executed) {
Contributor.getById(proposal.contributorId).then((contributor) => {
messageRoom(`* ${proposal.amount} kredits to ${contributor.name} for ${proposal.description}`);
});
}
});
messageRoom('https://kredits.kosmos.org');
}); });
}); });
@ -85,23 +104,22 @@ module.exports = async function(robot) {
// //
function watchContractEvents() { function watchContractEvents() {
ethProvider.getBlockNumber().then(blockNumber => { ethProvider.getBlockNumber().then((blockNumber) => {
// current block is the last mined one, thus we check from the next // current block is the last mined one, thus we check from the next
// mined one onwards to prevent getting previous events // mined one onwards to prevent getting previous events
let nextBlock = blockNumber + 1; let nextBlock = blockNumber + 1;
robot.logger.debug(`[hubot-kredits] Watching events from block ${nextBlock} onward`); robot.logger.debug(`[hubot-kredits] Watching events from block ${nextBlock} onward`);
ethProvider.resetEventsBlock(nextBlock); ethProvider.resetEventsBlock(nextBlock);
// TODO handle all known events (that make sense here) Operator.on('ProposalCreated', handleProposalCreated);
// Contribution.on('ContributorAdded', handleContributorAdded);
Contribution.on('ContributionAdded', handleContributionAdded);
}); });
} }
function handleContributionAdded(contributionId, contributorId, amount) { function handleProposalCreated(proposalId, creatorAccount, contributorId, amount) {
Contributor.getById(contributorId).then(_ => { Contributor.getById(contributorId).then((contributor) => {
Contribution.getById(contributionId).then(contribution => { Operator.getById(proposalId).then((proposal) => {
robot.logger.debug(`[hubot-kredits] Contribution #${contribution.id} added (${amount} kredits for "${contribution.description}")`); robot.logger.debug(`[hubot-kredits] Proposal created (${proposal.description})`);
// messageRoom(`Let's give ${contributor.name} some kredits for ${proposal.url} (${proposal.description}): https://kredits.kosmos.org`);
}); });
}); });
} }
@ -113,11 +131,6 @@ module.exports = async function(robot) {
// //
require('./integrations/github')(robot, kredits); require('./integrations/github')(robot, kredits);
require('./integrations/gitea')(robot, kredits);
if (typeof process.env.KREDITS_ZOOM_JWT !== 'undefined') {
require('./integrations/zoom')(robot, kredits);
}
if (typeof process.env.KREDITS_MEDIAWIKI_URL !== 'undefined') { if (typeof process.env.KREDITS_MEDIAWIKI_URL !== 'undefined') {
require('./integrations/mediawiki')(robot, kredits); require('./integrations/mediawiki')(robot, kredits);

View File

@ -1,165 +0,0 @@
const util = require('util');
const amountFromLabels = require('./utils/amount-from-labels');
const kindFromLabels = require('./utils/kind-from-labels');
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
module.exports = async function(robot, kredits) {
function messageRoom(message) {
robot.messageRoom(process.env.KREDITS_ROOM, message);
}
robot.logger.debug('[hubot-kredits] Loading Gitea integration...');
let repoBlackList = [];
if (process.env.KREDITS_GITEA_REPO_BLACKLIST) {
repoBlackList = process.env.KREDITS_GITEA_REPO_BLACKLIST.split(',');
robot.logger.debug('[hubot-kredits] Ignoring Gitea actions from ', util.inspect(repoBlackList));
}
const Contributor = kredits.Contributor;
const Contribution = kredits.Contribution;
function getContributorByGiteaUser(username) {
return Contributor.all().then(contributors => {
const contrib = contributors.find(c => {
return c.gitea_username === username;
});
if (!contrib) {
throw new Error(`No contributor found for ${username}`);
} else {
return contrib;
}
});
}
function createContribution(giteaUser, date, time, amount, kind, description, url, details) {
return getContributorByGiteaUser(giteaUser).then(contributor => {
robot.logger.debug(`[hubot-kredits] Creating contribution token for ${amount}₭S to ${giteaUser} for ${url}...`);
const contributionAttr = {
contributorId: contributor.id,
contributorIpfsHash: contributor.ipfsHash,
date,
time,
amount,
kind,
description,
url,
details
};
robot.logger.debug(`[hubot-kredits] contribution attributes:`);
robot.logger.debug(util.inspect(contributionAttr, { depth: 1, colors: true }));
return Contribution.add(contributionAttr).catch(error => {
robot.logger.error(`[hubot-kredits] Error:`, error);
messageRoom(`I tried to add a contribution for ${giteaUser} for ${url}, but I encountered an error when submitting the tx:`);
messageRoom(error.message);
});
});
}
async function handleGiteaIssueClosed(data) {
const issue = data.issue;
const repoName = data.repository.full_name;
const web_url = `${data.repository.html_url}/issues/${issue.number}`;
const description = `${repoName}: ${issue.title}`;
const labels = issue.labels.map(l => l.name);
const amount = amountFromLabels(labels);
const kind = kindFromLabels(labels);
const assignees = issue.assignees ? issue.assignees.map(a => a.login) : [];
[ date, time ] = issue.closed_at.split('T');
if (amount === 0) {
robot.logger.info('[hubot-kredits] Kredits amount from issue label is zero; ignoring');
return Promise.resolve();
} else if (repoBlackList.includes(repoName)) {
robot.logger.debug(`[hubot-kredits] ${repoName} is on black list; ignoring`);
return Promise.resolve();
}
let recipients;
if (assignees.length > 0) {
recipients = assignees;
} else {
recipients = [issue.user.login];
}
for (const recipient of recipients) {
try {
await createContribution(recipient, date, time, amount,
kind, description, web_url,
{ issue, repository: data.repository });
await sleep(60000);
}
catch (err) { robot.logger.error(err); }
}
return Promise.resolve();
}
async function handleGiteaPullRequestClosed(data) {
const pull_request = data.pull_request;
const repoName = data.repository.full_name;
const web_url = pull_request.html_url;
const description = `${repoName}: ${pull_request.title}`;
const labels = pull_request.labels.map(l => l.name);
const amount = amountFromLabels(labels);
const kind = kindFromLabels(labels);
const assignees = pull_request.assignees ? pull_request.assignees.map(a => a.login) : [];
[ date, time ] = pull_request.merged_at.split('T');
if (amount === 0) {
robot.logger.info('[hubot-kredits] Kredits amount from issue label is zero; ignoring');
return Promise.resolve();
} else if (repoBlackList.includes(repoName)) {
robot.logger.debug(`[hubot-kredits] ${repoName} is on black list; ignoring`);
return Promise.resolve();
}
let recipients;
if (assignees.length > 0) {
recipients = assignees;
} else {
recipients = [pull_request.user.login];
}
for (const recipient of recipients) {
try {
await createContribution(recipient, date, time, amount,
kind, description, web_url,
{ pull_request, repository: data.repository });
await sleep(60000);
}
catch (err) { robot.logger.error(err); }
}
return Promise.resolve();
}
robot.router.post('/incoming/kredits/gitea/'+process.env.KREDITS_WEBHOOK_TOKEN, (req, res) => {
const evt = req.header('X-Gitea-Event');
let data = req.body;
// For some reason data is contained in a payload property on one
// machine, but directly in the root of the object on others
if (data.payload) { data = JSON.parse(data.payload); }
robot.logger.info(`Received Gitea hook. Event: ${evt}, action: ${data.action}`);
if (evt === 'pull_request' && data.action === 'closed' && data.pull_request.merged) {
handleGiteaPullRequestClosed(data);
res.sendStatus(200);
}
else if (evt === 'issues' && data.action === 'closed') {
handleGiteaIssueClosed(data);
res.sendStatus(200);
} else {
res.sendStatus(200);
}
});
};

View File

@ -1,14 +1,5 @@
const util = require('util'); const util = require('util');
const fetch = require('node-fetch'); const fetch = require('node-fetch');
const session = require('express-session');
const cors = require('cors');
const grant = require('grant').express();
const amountFromLabels = require('./utils/amount-from-labels');
const kindFromLabels = require('./utils/kind-from-labels');
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
module.exports = async function(robot, kredits) { module.exports = async function(robot, kredits) {
@ -24,14 +15,12 @@ module.exports = async function(robot, kredits) {
robot.logger.debug('[hubot-kredits] Ignoring GitHub actions from ', util.inspect(repoBlackList)); robot.logger.debug('[hubot-kredits] Ignoring GitHub actions from ', util.inspect(repoBlackList));
} }
const kreditsWebUrl = process.env.KREDITS_WEB_URL || 'https://kredits.kosmos.org';
const Contributor = kredits.Contributor; const Contributor = kredits.Contributor;
const Contribution = kredits.Contribution; const Operator = kredits.Operator;
function getContributorByGithubUser(username) { function getContributorByGithubUser(username) {
return Contributor.all().then(contributors => { return Contributor.all().then(contributors => {
const contrib = contributors.find(c => { let contrib = contributors.find(c => {
return c.github_username === username; return c.github_username === username;
}); });
if (!contrib) { if (!contrib) {
@ -42,48 +31,62 @@ module.exports = async function(robot, kredits) {
}); });
} }
function createContribution(githubUser, date, time, amount, kind, description, url, details) { function createProposal(githubUser, amount, description, url, details) {
return getContributorByGithubUser(githubUser).then(contributor => { return getContributorByGithubUser(githubUser).then(contributor => {
robot.logger.info(`[hubot-kredits] Creating contribution token for ${amount}₭S to ${githubUser} for ${url}...`); robot.logger.debug(`[hubot-kredits] Creating proposal to issue ${amount}₭S to ${githubUser} for ${url}...`);
const contributionAttr = { let contributionAttr = {
contributorId: contributor.id, contributorId: contributor.id,
amount: amount,
contributorIpfsHash: contributor.ipfsHash, contributorIpfsHash: contributor.ipfsHash,
date,
time,
amount,
kind,
description,
url, url,
details description,
details,
kind: 'dev'
}; };
robot.logger.debug(`[hubot-kredits] contribution attributes:`); return Operator.addProposal(contributionAttr).catch(error => {
robot.logger.debug(util.inspect(contributionAttr, { depth: 1, colors: true }));
return Contribution.add(contributionAttr).catch(error => {
robot.logger.error(`[hubot-kredits] Error:`, error); robot.logger.error(`[hubot-kredits] Error:`, error);
messageRoom(`I tried to add a contribution for ${githubUser} for ${url}, but I encountered an error when submitting the tx:`); messageRoom(`I wanted to propose giving kredits to GitHub user ${githubUser} for ${url}, but I cannot find their info. Please add them as a contributor: https://kredits.kosmos.org`);
messageRoom(error.message);
}); });
}); });
} }
async function handleGitHubIssueClosed(data) { function amountFromIssueLabels(issue) {
let recipients; let kreditsLabel = issue.labels.map(l => l.name)
const issue = data.issue; .filter(n => n.match(/^kredits/))[0];
const assignees = issue.assignees.map(a => a.login); // No label, no kredits
const web_url = issue.html_url; if (typeof kreditsLabel === 'undefined') { return 0; }
[date, time] = issue.closed_at.split('T'); // TODO move to config maybe?
const labels = issue.labels.map(l => l.name); let amount;
const amount = amountFromLabels(labels); switch(kreditsLabel) {
const kind = kindFromLabels(labels); case 'kredits-1':
const repoName = issue.repository_url.match(/.*\/(.+\/.+)$/)[1]; amount = 500;
const description = `${repoName}: ${issue.title}`; break;
case 'kredits-2':
amount = 1500;
break;
case 'kredits-3':
amount = 5000;
break;
}
return amount;
}
function handleGitHubIssueClosed(data) {
let recipients;
let issue = data.issue;
let assignees = issue.assignees.map(a => a.login);
let web_url = issue.html_url;
let amount = amountFromIssueLabels(issue);
let repoName = issue.repository_url.match(/.*\/(.+\/.+)$/)[1];
let description = `${repoName}: ${issue.title}`;
if (amount === 0) { if (amount === 0) {
robot.logger.info('[hubot-kredits] Kredits amount from issue label is zero; ignoring'); robot.logger.info('[hubot-kredits] Proposal amount from issue label is zero; ignoring');
return Promise.resolve(); return Promise.resolve();
} else if (repoBlackList.includes(repoName)) { } else if (repoBlackList.includes(repoName)) {
robot.logger.debug(`[hubot-kredits] ${repoName} is on black list; ignoring`); robot.logger.debug(`[hubot-kredits] ${repoName} is on black list; ignoring`);
@ -96,25 +99,23 @@ module.exports = async function(robot, kredits) {
recipients = [issue.user.login]; recipients = [issue.user.login];
} }
for (const recipient of recipients) { let proposalPromises = [];
try { recipients.forEach(recipient => {
await createContribution(recipient, date, time, amount, kind, description, web_url, issue); proposalPromises.push(
await sleep(60000); createProposal(recipient, amount, description, web_url, issue)
} .catch(err => robot.logger.error(err))
catch (err) { robot.logger.error(err); } );
} });
return Promise.resolve(); return Promise.all(proposalPromises);
} }
function handleGitHubPullRequestClosed(data) { function handleGitHubPullRequestClosed(data) {
let recipients; let recipients;
const pull_request = data.pull_request; let pull_request = data.pull_request;
const assignees = pull_request.assignees.map(a => a.login); let assignees = pull_request.assignees.map(a => a.login);
const web_url = pull_request._links.html.href; let web_url = pull_request._links.html.href;
const pr_issue_url = pull_request.issue_url; let pr_issue_url = pull_request.issue_url;
[date, time] = pull_request.merged_at.split('T');
if (assignees.length > 0) { if (assignees.length > 0) {
recipients = assignees; recipients = assignees;
@ -129,35 +130,34 @@ module.exports = async function(robot, kredits) {
} }
return response.json(); return response.json();
}) })
.then(async (issue) => { .then(issue => {
const labels = issue.labels.map(l => l.name); let amount = amountFromIssueLabels(issue);
const amount = amountFromLabels(labels); let repoName = pull_request.base.repo.full_name;
const kind = kindFromLabels(labels); let description = `${repoName}: ${pull_request.title}`;
const repoName = pull_request.base.repo.full_name;
const description = `${repoName}: ${pull_request.title}`;
if (amount === 0) { if (amount === 0) {
robot.logger.info('[hubot-kredits] Kredits amount from issue label is zero; ignoring'); robot.logger.info('[hubot-kredits] Proposal amount from issue label is zero; ignoring');
return Promise.resolve(); return Promise.resolve();
} else if (repoBlackList.includes(repoName)) { } else if (repoBlackList.includes(repoName)) {
robot.logger.debug(`[hubot-kredits] ${repoName} is on black list; ignoring`); robot.logger.debug(`[hubot-kredits] ${repoName} is on black list; ignoring`);
return Promise.resolve(); return Promise.resolve();
} }
for (const recipient of recipients) { let proposalPromises = [];
try { recipients.forEach(recipient => {
await createContribution(recipient, date, time, amount, kind, description, web_url, pull_request); console.debug(`[hubot-kredits] Creating proposal for ${recipient}...`);
await sleep(60000); proposalPromises.push(
} createProposal(recipient, amount, description, web_url, pull_request)
catch (err) { robot.logger.error(err); } .catch(err => robot.logger.error(err))
} );
});
return Promise.resolve(); return Promise.all(proposalPromises);
}); });
} }
robot.router.post('/incoming/kredits/github/'+process.env.KREDITS_WEBHOOK_TOKEN, (req, res) => { robot.router.post('/incoming/kredits/github/'+process.env.KREDITS_WEBHOOK_TOKEN, (req, res) => {
const evt = req.header('X-GitHub-Event'); let evt = req.header('X-GitHub-Event');
let data = req.body; let data = req.body;
// For some reason data is contained in a payload property on one // For some reason data is contained in a payload property on one
// machine, but directly in the root of the object on others // machine, but directly in the root of the object on others
@ -165,112 +165,14 @@ module.exports = async function(robot, kredits) {
robot.logger.info(`Received GitHub hook. Event: ${evt}, action: ${data.action}`); robot.logger.info(`Received GitHub hook. Event: ${evt}, action: ${data.action}`);
if (evt === 'pull_request' && data.action === 'closed' && data.pull_request.merged) { if (evt === 'pull_request' && data.action === 'closed') {
handleGitHubPullRequestClosed(data); handleGitHubPullRequestClosed(data).then(() => res.send(200));
res.sendStatus(200);
} }
else if (evt === 'issues' && data.action === 'closed') { else if (evt === 'issues' && data.action === 'closed') {
handleGitHubIssueClosed(data); handleGitHubIssueClosed(data).then(() => res.send(200));
res.sendStatus(200);
} else { } else {
res.sendStatus(200); res.send(200);
} }
}); });
//
// GitHub signup
//
if (process.env.KREDITS_GITHUB_KEY && process.env.KREDITS_GITHUB_SECRET) {
const grantConfig = {
defaults: {
origin: (process.env.KREDITS_GRANT_ORIGIN || 'http://localhost:8888'),
prefix: '/kredits/signup/connect',
transport: 'session',
response: 'tokens',
},
github: {
key: process.env.KREDITS_GITHUB_KEY,
secret: process.env.KREDITS_GITHUB_SECRET,
callback: '/kredits/signup/github'
}
};
robot.router.use(session({
secret: process.env.KREDITS_SESSION_SECRET || 'grant',
resave: false,
saveUninitialized: false
}));
robot.router.use(grant(grantConfig));
robot.router.get('/kredits/signup/github', async (req, res) => {
const access_token = req.session.grant.response.access_token;
res.redirect(`${kreditsWebUrl}/signup/github#access_token=${access_token}`);
});
robot.router.options('/kredits/signup/github', cors());
robot.router.post('/kredits/signup/github', cors(), async (req, res) => {
const accessToken = req.body.accessToken;
if (!accessToken) {
res.status(400).json({});
return;
}
let githubResponse;
try {
githubResponse = await fetch('https://api.github.com/user', {
headers: {
'Accept': 'application/vnd.github.v3+json',
'Authorization': `token ${accessToken}`
}
});
} catch (error) {
robot.logger.error('[hubot-kredits] Fetching user data from GitHub failed:', error);
res.status(500).json({ error });
};
if (githubResponse.status >= 300) {
res.status(githubResponse.status).json({});
return;
}
const user = await githubResponse.json();
const contributor = await kredits.Contributor.findByAccount({
site: 'github.com',
username: user.login
});
if (!contributor) {
let contributorAttr = {};
contributorAttr.account = req.body.account;
contributorAttr.name = user.name || user.login;
contributorAttr.kind = "person";
contributorAttr.url = user.blog;
contributorAttr.github_username = user.login;
contributorAttr.github_uid = user.id;
kredits.Contributor.add(contributorAttr, { gasLimit: 350000 })
.then(transaction => {
robot.logger.info('[hubot-kredits] Contributor added from GitHub signup', transaction.hash);
res.status(201);
res.json({
transactionHash: transaction.hash,
github_username: user.login
});
}, error => {
robot.logger.error(`[hubot-kredits] Adding contributor failed: ${error}`);
res.status(422);
res.json({ error })
});
} else {
res.json({
github_username: user.login
});
}
});
} else {
robot.logger.warning('[hubot-kredits] No KREDITS_GITHUB_KEY and KREDITS_GITHUB_SECRET configured for OAuth signup');
}
}; };

View File

@ -4,10 +4,6 @@ const fetch = require('node-fetch');
const groupArray = require('group-array'); const groupArray = require('group-array');
const cron = require('node-cron'); const cron = require('node-cron');
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
module.exports = async function(robot, kredits) { module.exports = async function(robot, kredits) {
function messageRoom(message) { function messageRoom(message) {
@ -17,7 +13,7 @@ module.exports = async function(robot, kredits) {
robot.logger.debug('[hubot-kredits] Loading MediaWiki integration...') robot.logger.debug('[hubot-kredits] Loading MediaWiki integration...')
const Contributor = kredits.Contributor; const Contributor = kredits.Contributor;
const Contribution = kredits.Contribution; const Operator = kredits.Operator;
const wikiURL = process.env.KREDITS_MEDIAWIKI_URL; const wikiURL = process.env.KREDITS_MEDIAWIKI_URL;
const apiURL = wikiURL + 'api.php'; const apiURL = wikiURL + 'api.php';
@ -33,23 +29,22 @@ module.exports = async function(robot, kredits) {
}); });
} }
function createContribution(username, date, amount, description, url, details={}) { function createProposal(username, amount, description, url, details={}) {
return getContributorByWikiUser(username).then(contributor => { return getContributorByWikiUser(username).then(contributor => {
robot.logger.debug(`[hubot-kredits] Creating contribution token for ${amount}₭S to ${contributor.name} for ${url}...`); robot.logger.debug(`[hubot-kredits] Creating proposal to issue ${amount}₭S to ${contributor.name} for ${url}...`);
let contribution = { let contribution = {
contributorId: contributor.id, contributorId: contributor.id,
contributorIpfsHash: contributor.ipfsHash,
date,
amount: amount, amount: amount,
contributorIpfsHash: contributor.ipfsHash,
url, url,
description, description,
details, details,
kind: 'docs' kind: 'docs'
}; };
return Contribution.add(contribution).catch(error => { return Operator.addProposal(contribution).catch(error => {
robot.logger.error(`[hubot-kredits] Adding contribution failed:`, error); robot.logger.error(`[hubot-kredits] Adding proposal failed:`, error);
}); });
}).catch(() => { }).catch(() => {
robot.logger.info(`[hubot-kredits] No contributor found for ${username}`); robot.logger.info(`[hubot-kredits] No contributor found for ${username}`);
@ -111,22 +106,25 @@ module.exports = async function(robot, kredits) {
return results; return results;
} }
async function createContributions (changes) { function createProposals (changes) {
for (const user of Object.keys(changes)) { let promises = [];
await createContributionForUserChanges(user, changes[user]);
await sleep(60000); Object.keys(changes).forEach(user => {
} promises.push(createProposalForUserChanges(user, changes[user]));
});
return Promise.all(promises);
} }
function pageTitlesFromChanges(changes) { function pageTitlesFromChanges(changes) {
return [...new Set(changes.map(c => `"${c.title}"`))].join(', '); return [...new Set(changes.map(c => `"${c.title}"`))].join(', ');
} }
// Currently not used
function calculateAmountForChanges(details) { function calculateAmountForChanges(details) {
let amount; let amount;
if (details.charsAdded < 500) { if (details.charsAdded < 280) {
// less than a tweet
amount = 500; amount = 500;
} else if (details.charsAdded < 2000) { } else if (details.charsAdded < 2000) {
amount = 1500; amount = 1500;
@ -137,12 +135,9 @@ module.exports = async function(robot, kredits) {
return amount; return amount;
} }
function createContributionForUserChanges (user, changes) { function createProposalForUserChanges (user, changes) {
const dateNow = new Date();
const dateYesterday = dateNow.setDate(dateNow.getDate() - 1);
const date = (new Date(dateYesterday)).toISOString().split('T')[0];
const details = analyzeUserChanges(user, changes); const details = analyzeUserChanges(user, changes);
const amount = 500; const amount = calculateAmountForChanges(details);
let desc = `Added ${details.charsAdded} characters of text.`; let desc = `Added ${details.charsAdded} characters of text.`;
if (details.pagesChanged.length > 0) { if (details.pagesChanged.length > 0) {
@ -161,7 +156,7 @@ module.exports = async function(robot, kredits) {
url = `${wikiURL}index.php?title=${rc.title}&diff=${rc.revid}&oldid=${rc.old_revid}`; url = `${wikiURL}index.php?title=${rc.title}&diff=${rc.revid}&oldid=${rc.old_revid}`;
} }
return createContribution(user, date, amount, desc, url, details); return createProposal(user, amount, desc, url, details);
} }
function updateTimestampForNextFetch () { function updateTimestampForNextFetch () {
@ -172,7 +167,7 @@ module.exports = async function(robot, kredits) {
function processWikiChangesSinceLastRun () { function processWikiChangesSinceLastRun () {
fetchChanges() fetchChanges()
.then(res => groupChangesByUser(res)) .then(res => groupChangesByUser(res))
.then(res => createContributions(res)) .then(res => createProposals(res))
.then(() => updateTimestampForNextFetch()); .then(() => updateTimestampForNextFetch());
} }

View File

@ -1,21 +0,0 @@
module.exports = function (labels) {
const kreditsLabel = labels.filter(n => n.match(/^kredits/))[0];
// No label, no kredits
if (typeof kreditsLabel === 'undefined') { return 0; }
// TODO move amounts to config?
let amount;
switch(kreditsLabel) {
case 'kredits-1':
amount = 500;
break;
case 'kredits-2':
amount = 1500;
break;
case 'kredits-3':
amount = 5000;
break;
}
return amount;
};

View File

@ -1,18 +0,0 @@
module.exports = function (labels) {
let kind = 'dev';
if (labels.find(l => l.match(/ops|operations/))) {
kind = 'ops';
}
else if (labels.find(l => l.match(/docs|documentation/))) {
kind = 'docs';
}
else if (labels.find(l => l.match(/design/))) {
kind = 'design';
}
else if (labels.find(l => l.match(/community/))) {
kind = 'community';
}
return kind;
};

View File

@ -1,102 +0,0 @@
const fetch = require('node-fetch');
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
module.exports = async function(robot, kredits) {
function messageRoom(message) {
robot.messageRoom(process.env.KREDITS_ROOM, message);
}
const { Contributor, Contribution } = kredits;
const kreditsContributionAmount = process.env.KREDITS_ZOOM_CONTRIBUTION_AMOUNT || 500;
const kreditsContributionKind = 'community';
const zoomAccessToken = process.env.KREDITS_ZOOM_JWT;
async function createContributionFor (displayName, meeting) {
const contributor = await getContributorByZoomDisplayName(displayName);
if (!contributor) {
robot.logger.info(`[hubot-kredits] Contributor not found: Zoom display name: ${displayName}`);
messageRoom(`I tried to add a contribution for zoom user ${displayName}, but did not find a matching contributor profile.`);
return Promise.resolve();
}
const contribution = {
contributorId: contributor.id,
contributorIpfsHash: contributor.ipfsHash,
amount: kreditsContributionAmount,
kind: kreditsContributionKind,
description: 'Team/Community Call',
date: meeting.end_time.split('T')[0],
time: meeting.end_time.split('T')[1]
}
return Contribution.add(contribution)
.then(tx => {
robot.logger.info(`[hubot-kredits] Contribution created: ${tx.hash}`);
})
.catch(error => {
robot.logger.error(`[hubot-kredits] Adding contribution for Zoom call failed:`, error);
});
}
function getContributorByZoomDisplayName(displayName) {
return Contributor.findByAccount({ site: 'zoom.us', username: displayName });
}
function request(path) {
return fetch(
`https://api.zoom.us/v2${path}`,
{headers: {authorization: `Bearer ${zoomAccessToken}`}}
);
}
function getMeetingParticipants(meetingUUID) {
return request(`/past_meetings/${meetingUUID}/participants`)
.then(response => response.json())
.then(json => json.participants)
}
function getMeetingDetails(meetingUUID) {
return request(`/past_meetings/${meetingUUID}`)
.then(r => r.json());
}
async function handleZoomMeetingEnded(data) {
const meetingDetails = await getMeetingDetails(data.uuid);
const participants = await getMeetingParticipants(data.uuid);
const names = Array.from(new Set(participants.map(p => p.name)));
if (meetingDetails.duration < 15 || names.length < 3) {
robot.logger.info(`[hubot-kredits] Ignoring zoom call ${data.uuid} (duration: ${meetingDetails.duration}, participants_count: ${meetingDetails.participants_count})`);
return;
}
for (const displayName of names) {
await createContributionFor(displayName, meetingDetails);
await sleep(60000); // potentially to prevent too many transactions at the sametime. transactions need to be ordered because of the nonce. not sure though if this is needed.
};
}
robot.router.post('/incoming/kredits/zoom/'+process.env.KREDITS_WEBHOOK_TOKEN, (req, res) => {
let data = req.body;
const eventName = data.event;
const payload = data.payload;
const object = payload.object;
if (eventName === 'meeting.ended' && (
!process.env.KREDITS_ZOOM_MEETING_WHITELIST ||
process.env.KREDITS_ZOOM_MEETING_WHITELIST.split(',').includes(object.id)
)) {
handleZoomMeetingEnded(object);
}
res.sendStatus(200);
})
}

7082
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,32 +1,22 @@
{ {
"name": "@kredits/hubot-kredits", "name": "hubot-kredits",
"version": "4.1.0", "version": "2.1.3",
"description": "Kosmos Kredits functionality for chat bots", "description": "Kosmos Kredits functionality for chat bots",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
}, },
"bin": { "bin": {
"create-wallet": "scripts/create-wallet.js", "create-wallet": "scripts/create-wallet.js"
"review-kredits": "scripts/review-kredits.js"
}, },
"dependencies": { "dependencies": {
"@ethersproject/experimental": "5.7.0", "ethers": "^3.0.15",
"@kredits/contracts": "^7.3.0", "group-array": "^0.3.3",
"axios": "^1.7.9", "kosmos-schemas": "^1.1.2",
"cors": "^2.8.5", "node-cron": "^1.2.1",
"dotenv": "^16.3.1", "node-fetch": "^1.6.3",
"eth-provider": "^0.13.6", "prompt": "^1.0.0",
"ethers": "^5.0.5", "kredits-contracts": "3.x"
"express": "^4.18.2",
"express-session": "^1.17.3",
"grant-express": "^5.4.8",
"group-array": "^1.0.0",
"kosmos-schemas": "^2.2.1",
"node-cron": "^2.0.3",
"node-fetch": "^2.6.12",
"prompt": "^1.3.0",
"yargs": "^17.7.2"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -40,8 +30,7 @@
"author": "Kosmos Developers <mail@kosmos.org>", "author": "Kosmos Developers <mail@kosmos.org>",
"contributors": [ "contributors": [
"Sebastian Kippe <sebastian@kip.pe>", "Sebastian Kippe <sebastian@kip.pe>",
"Michael Bumann <hello@michaelbumann.com>", "Michael Bumann <hello@michaelbumann.com>"
"Garret Alfert <alfert@wevelop.de>"
], ],
"license": "MIT", "license": "MIT",
"bugs": { "bugs": {

View File

@ -1,95 +0,0 @@
const axios = require('axios');
module.exports = class GiteaReviews {
client = null;
kreditsAmounts = null;
pageLimit = 50;
constructor (token, kreditsAmounts) {
this.kreditsAmounts = kreditsAmounts;
this.client = axios.create({
baseURL: 'https://gitea.kosmos.org/api/v1',
headers: {
'accepts': 'application/json',
'Authorization': `token ${token}`
}
});
}
async getReviewContributions (repos, startDate, endDate) {
let pulls = [];
let reviewContributions = {}
await Promise.all(repos.map(async (repo) => {
let page = 1;
let result;
do {
try {
result = await this.client.get(`/repos/${repo}/pulls?state=closed&limit=${this.pageLimit}&page=${page}`);
} catch(error) {
console.log(`failed to fetch PRs for repo ${repo}:`, error.message);
continue;
}
if (!result || !result.data || result.data.length === 0) {
continue;
}
let pullRequests = result.data.filter(pr => {
if (!pr.merged) return false; // only interested in merged PRs
// check if the PR has been merged in the given timeframe
const mergeDate = new Date(pr.merged_at);
if (mergeDate < startDate || mergeDate > endDate) return false;
// check if the PR has a kredits label
return pr.labels.some(label => label.name.match(/kredits-[123]/));
});
await Promise.all(pullRequests.map(async (pr) => {
let reviews;
try {
reviews = await this.client.get(`/repos/${repo}/pulls/${pr.number}/reviews`);
} catch(error) {
console.log(`failed to fetch reviews for repo ${repo}, PR ${pr.number}:`, error.message);
return;
}
if (!reviews || !reviews.data || reviews.data.length === 0) {
return;
}
reviews = reviews.data.filter(review => {
return ['APPROVED', 'REJECTED'].includes(review.state);
});
reviews.forEach(review => {
// console.debug(`Review from /repos/${repo}/pulls/${pr.number}`);
if (typeof reviewContributions[review.user.login] === 'undefined') {
reviewContributions[review.user.login] = [];
}
let kreditsLabel = pr.labels.find(label => label.name.match(/kredits-[123]/));
reviewContributions[review.user.login].push({
pr,
prNumber: pr.number,
review,
reviewState: review.state,
kredits: this.kreditsAmounts[kreditsLabel.name]
});
});
}));
page++;
} while (result && result.data && result.data.length > 0);
}));
return reviewContributions;
}
}

View File

@ -1,96 +0,0 @@
const axios = require('axios');
module.exports = class GithubReviews {
client = null;
kreditsAmounts = null;
pageLimit = 100;
constructor (token, kreditsAmounts) {
this.kreditsAmounts = kreditsAmounts;
this.client = axios.create({
baseURL: 'https://api.github.com',
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'Kosmos Kredits for reviews',
'Authorization': `token ${token}`
}
});
}
async getReviewContributions (repos, startDate, endDate) {
let pulls = [];
let reviewContributions = {}
await Promise.all(repos.map(async (repo) => {
let page = 1;
let result;
do {
try {
result = await this.client.get(`/repos/${repo}/pulls?state=closed&perPage=${this.pageLimit}&page=${page}`);
} catch(error) {
console.log(`failed to fetch PRs for repo ${repo}:`, error.message);
continue;
}
if (!result || !result.data || result.data.length === 0) {
continue;
}
let pullRequests = result.data.filter(pr => {
if (!pr.merged_at) return false; // only interested in merged PRs
// check if the PR has been merged in the given timeframe
const mergeDate = new Date(pr.merged_at);
if (mergeDate < startDate || mergeDate > endDate) return false;
// check if the PR has a kredits label
return pr.labels.some(label => label.name.match(/kredits-[123]/));
});
await Promise.all(pullRequests.map(async (pr) => {
let reviews;
try {
reviews = await this.client.get(`/repos/${repo}/pulls/${pr.number}/reviews`);
} catch(error) {
console.log(`failed to fetch reviews for repo ${repo}, PR ${pr.number}:`, error.message);
return;
}
if (!reviews || !reviews.data || reviews.data.length === 0) {
return;
}
reviews = reviews.data.filter(review => {
return ['APPROVED', 'REJECTED'].includes(review.state);
});
reviews.forEach(review => {
// console.debug(`Review from /repos/${repo}/pulls/${pr.number}`);
if (typeof reviewContributions[review.user.login] === 'undefined') {
reviewContributions[review.user.login] = [];
}
let kreditsLabel = pr.labels.find(label => label.name.match(/kredits-[123]/));
reviewContributions[review.user.login].push({
pr,
prNumber: pr.number,
review,
reviewState: review.state,
kredits: this.kreditsAmounts[kreditsLabel.name]
});
});
}));
page++;
} while (result && result.data && result.data.length > 0);
}));
return reviewContributions;
}
}

View File

@ -1,25 +0,0 @@
{
"github": [
"67P/hubot-kredits",
"67P/hubot-remotestorage-logger",
"67P/hyperchannel",
"67P/kredits-signup",
"67P/kredits-web",
"67P/waves",
"sockethub/sockethub"
],
"gitea": [
"kosmos/akkounts",
"kosmos/botka",
"kosmos/chef",
"kosmos/gitea.kosmos.org",
"kosmos/hal8000",
"kosmos/ipfs-cookbook",
"kosmos/rs-module-kosmos",
"kosmos/schemas",
"kosmos/website",
"kosmos/wormhole",
"kredits/contracts",
"kredits/ipfs-pinner"
]
}

View File

@ -1,246 +0,0 @@
#!/usr/bin/env node
require('dotenv').config();
const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers');
const util = require('util');
const fs = require('fs');
const GiteaReviews = require('./lib/gitea-reviews');
const GithubReviews = require('./lib/github-reviews');
const ethers = require('ethers');
const NonceManager = require('@ethersproject/experimental').NonceManager;
const Kredits = require('@kredits/contracts');
const walletPath = process.env.KREDITS_WALLET_PATH || './wallet.json';
const walletJson = fs.readFileSync(walletPath);
const providerUrl = process.env.KREDITS_PROVIDER_URL || 'http://localhost:7545';
const ipfsConfig = {
host: process.env.IPFS_API_HOST || 'localhost',
port: process.env.IPFS_API_PORT || '5001',
protocol: process.env.IPFS_API_PROTOCOL || 'http',
};
console.log('ipfsConfig:', ipfsConfig);
const kreditsAmounts = {
'kredits-1': 100,
'kredits-2': 300,
'kredits-3': 1000,
};
const repos = require('./repos.json');
const argv = yargs(hideBin(process.argv))
.option('start', {
alias: 's',
description: 'Include reviews for PRs merged after this date',
})
.option('end', {
alias: 'e',
description: 'Include reviews for PRs merged before this date',
})
.option('dry', {
alias: 'd',
type: 'boolean',
description: 'Only list contribution details without creating them',
})
.help()
.version()
.demandOption('start', 'Please provide a start date')
.default('end', function now() {
return new Date().toISOString().split('.')[0] + 'Z';
})
.example([
[
'$0 --start 2020-11-01 --end 2020-11-30T23:59:59Z',
'Create contributions for reviews of pull requests merged in November 2020',
],
[
'$0 --start 2021-01-01',
'Create contributions for reviews of pull requests merged from Januar 2021 until now',
],
]).argv;
const startTimestamp = Date.parse(argv.start);
const endTimestamp = Date.parse(argv.end);
if (isNaN(startTimestamp)) {
console.log('The provided start date is invalid');
process.exit(1);
}
if (isNaN(endTimestamp)) {
console.log('The provided end date is invalid');
process.exit(1);
}
const startDate = new Date(startTimestamp);
const endDate = new Date(endTimestamp);
async function getAllReviews(repos, startDate, endDate) {
const githubReviews = new GithubReviews(
process.env.GITHUB_TOKEN,
kreditsAmounts
);
const giteaReviews = new GiteaReviews(
process.env.GITEA_TOKEN,
kreditsAmounts
);
return Promise.all([
githubReviews.getReviewContributions(repos.github, startDate, endDate),
giteaReviews.getReviewContributions(repos.gitea, startDate, endDate),
]).then((reviews) => {
return { github: reviews[0], gitea: reviews[1] };
});
}
async function initializeKredits() {
//
// Wallet setup
//
let wallet;
try {
wallet = await ethers.Wallet.fromEncryptedJson(walletJson, process.env.KREDITS_WALLET_PASSWORD);
} catch(error) {
console.warn('Could not load wallet:', error);
process.exit(1);
}
//
// Solidity VM provider/node setup
//
console.log('Using blockchain node/API at', providerUrl);
const ethProvider = new ethers.providers.JsonRpcProvider(providerUrl);
const signer = new NonceManager(wallet.connect(ethProvider));
//
// Kredits contracts setup
//
const opts = { ipfsConfig };
let kredits;
try {
kredits = await new Kredits(signer.provider, signer, opts).init();
} catch(error) {
console.warn('Could not set up kredits:', error);
process.exit(1);
}
console.log('Wallet address: ' + wallet.address);
//
// Check robot's wallet balance and alert when it's broke
//
ethProvider.getBalance(wallet.address).then(balance => {
console.log('Wallet balance: ' + ethers.utils.formatEther(balance) + ' RBTC');
if (balance.lt(ethers.utils.parseEther('0.0001'))) {
console.warn(`I\'m broke! Please send some RBTC to ${wallet.address} first.`);
process.exit(1);
}
});
return kredits;
}
async function generateContributionData(reviews, Contributor) {
const contributors = await Contributor.all();
const contributionData = {};
const nextDay = new Date(endDate);
nextDay.setUTCDate(nextDay.getUTCDate() + 1);
nextDay.setUTCHours(0, 0, 0, 0);
const nextDayStr = nextDay.toISOString().split('.')[0] + 'Z';
[date, time] = nextDayStr.split('T');
function addContributionDataForPlatform(platform) {
for (const [username, platformReviews] of Object.entries(
reviews[platform]
)) {
const contributor = contributors.find((c) => {
return c[`${platform}_username`] === username;
});
if (!contributor) {
console.log(
`Could not find contributor for ${platform} user "${username}"`
);
continue;
}
const urls = platformReviews.map((review) => review.pr.html_url);
const kreditsAmount = platformReviews.reduce((amount, review) => {
return review.kredits + amount;
}, 0);
if (typeof contributionData[contributor.name] !== 'undefined') {
contributionData[contributor.name].amount += kreditsAmount;
contributionData[contributor.name].details.pullRequests.push(...urls);
} else {
contributionData[contributor.name] = {
contributorId: contributor.id,
contributorIpfsHash: contributor.ipfsHash,
date,
time,
amount: kreditsAmount,
kind: 'dev',
details: {
kind: 'review',
pullRequests: urls,
},
};
}
}
}
addContributionDataForPlatform('gitea');
addContributionDataForPlatform('github');
return contributionData;
}
Promise.all([
initializeKredits(),
getAllReviews(repos, startDate, endDate),
]).then((values) => {
const kredits = values[0];
const reviews = values[1];
const Contributor = kredits.Contributor;
const Contribution = kredits.Contribution;
async function createContribution(nickname, attrs) {
console.log(`Creating review contribution for ${nickname}...`);
console.log(util.inspect(attrs, { depth: 1, colors: true }));
return Contribution.add(attrs)
.catch(error => {
console.error(`Error:`, error.message);
});
}
generateContributionData(reviews, Contributor).then(
async (contributionData) => {
if (argv.dry) {
console.log('Contributions:');
console.log(util.inspect(contributionData, { depth: 3, colors: true }));
return;
} else {
for (const nickname of Object.keys(contributionData)) {
const description = `Reviewed ${contributionData[nickname].details.pullRequests.length} pull requests (from ${startDate.toISOString().split('T')[0]} to ${endDate.toISOString().split('T')[0]})`;
await createContribution(nickname, {
...contributionData[nickname],
description,
});
}
}
}
);
});