Compare commits

..

No commits in common. "master" and "v3.5.0" have entirely different histories.

14 changed files with 1767 additions and 6018 deletions

View File

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

1
.gitignore vendored
View File

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

View File

@ -1,4 +1,4 @@
[![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
@ -10,20 +10,15 @@ contributions.
## Setup ## Setup
### Wallet ### Ethereum wallet
You will need a keypair/wallet for your bot, so it can interact with the smart You will need an Ethereum wallet for your bot, so it can interact with the
contracts. `npm run create-wallet` will do the job for you. Ethereum 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 The wallet must be funded with enough ETH to interact with the contracts.
contracts (i.e. it must be able to pay gas/tx fees)
### Contract permissions ### Contract permissions
**Warning: outdated instructions!**
*TODO adapt instructions for new permission model*
The bot wallet needs the following Aragon contract permissions to interact The bot wallet needs the following Aragon contract permissions to interact
with [kredits-contracts]: with [kredits-contracts]:
@ -46,10 +41,11 @@ 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_WEB_URL` | URL of the Kredits Web app (default: `https://kredits.kosmos.org`) |
| `KREDITS_DAO_ADDRESS` | DAO Kernel address |
| `KREDITS_SESSION_SECRET` | Secret used by [grant](https://www.npmjs.com/package/grant) to sign the session ID | | `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_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") | | `KREDITS_GRANT_PROTOCOL` | Protocol (http or https) used by [grant](https://www.npmjs.com/package/grant") to generate the OAuth redirect URLs (default: "http") |
@ -120,29 +116,3 @@ wiki's API on its own.
[kredits-contracts]: https://github.com/67P/kredits-contracts [kredits-contracts]: https://github.com/67P/kredits-contracts
[GitHub OAuth app]: https://developer.github.com/apps/about-apps/#about-oauth-apps [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;
const daoAddress = process.env.KREDITS_DAO_ADDRESS;
const ipfsConfig = { const ipfsConfig = {
host: process.env.IPFS_API_HOST || 'localhost', host: process.env.IPFS_API_HOST || 'localhost',
@ -35,16 +37,22 @@ module.exports = async function(robot) {
// Ethereum provider/node setup // Ethereum provider/node setup
// //
robot.logger.info('[hubot-kredits] Using blockchain node/API at', providerUrl); let ethProvider;
if (providerUrl) {
const ethProvider = new ethers.providers.JsonRpcProvider(providerUrl); ethProvider = new ethers.providers.JsonRpcProvider(providerUrl);
const signer = new NonceManager(wallet.connect(ethProvider)); } else {
ethProvider = new ethers.getDefaultProvider('rinkeby');
}
const signer = wallet.connect(ethProvider);
// //
// Kredits contracts setup // Kredits contracts setup
// //
const opts = { ipfsConfig }; const opts = { ipfsConfig };
if (daoAddress) {
opts.addresses = { Kernel: daoAddress };
}
let kredits; let kredits;
try { try {
@ -54,8 +62,8 @@ module.exports = async function(robot) {
process.exit(1); process.exit(1);
} }
const Contributor = kredits.Contributor; const Contributor = kredits.Contributor;
const Proposal = kredits.Proposal;
const Contribution = kredits.Contribution; const Contribution = kredits.Contribution;
// 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 +72,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 +82,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 => {
Proposal.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 +114,31 @@ 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) Proposal.on('ProposalCreated', handleProposalCreated);
// Contribution.on('ContributorAdded', handleContributorAdded);
Contribution.on('ContributionAdded', handleContributionAdded); Contribution.on('ContributionAdded', handleContributionAdded);
}); });
} }
function handleProposalCreated(proposalId, creatorAccount, contributorId, amount) {
Contributor.getById(contributorId).then((contributor) => {
Proposal.getById(proposalId).then((proposal) => {
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`);
});
});
}
function handleContributionAdded(contributionId, contributorId, amount) { function handleContributionAdded(contributionId, contributorId, amount) {
Contributor.getById(contributorId).then(_ => { Contributor.getById(contributorId).then((contributor) => {
Contribution.getById(contributionId).then(contribution => { Contribution.getById(contributionId).then((contribution) => {
robot.logger.debug(`[hubot-kredits] Contribution #${contribution.id} added (${amount} kredits for "${contribution.description}")`); robot.logger.debug(`[hubot-kredits] Contribution #${contribution.id} added (${contribution.description})`);
}); });
}); });
} }

View File

@ -1,4 +1,5 @@
const util = require('util'); const util = require('util');
const fetch = require('node-fetch');
const amountFromLabels = require('./utils/amount-from-labels'); const amountFromLabels = require('./utils/amount-from-labels');
const kindFromLabels = require('./utils/kind-from-labels'); const kindFromLabels = require('./utils/kind-from-labels');
@ -55,7 +56,7 @@ module.exports = async function(robot, kredits) {
robot.logger.debug(`[hubot-kredits] contribution attributes:`); robot.logger.debug(`[hubot-kredits] contribution attributes:`);
robot.logger.debug(util.inspect(contributionAttr, { depth: 1, colors: true })); robot.logger.debug(util.inspect(contributionAttr, { depth: 1, colors: true }));
return Contribution.add(contributionAttr).catch(error => { return Contribution.addContribution(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 ${giteaUser} for ${url}, but I encountered an error when submitting the tx:`); messageRoom(`I tried to add a contribution for ${giteaUser} for ${url}, but I encountered an error when submitting the tx:`);
messageRoom(error.message); messageRoom(error.message);

View File

@ -1,8 +1,8 @@
const util = require('util'); const util = require('util');
const fetch = require('node-fetch'); const fetch = require('node-fetch');
const session = require('express-session'); const session = require('express-session');
const grant = require('grant-express');
const cors = require('cors'); const cors = require('cors');
const grant = require('grant').express();
const amountFromLabels = require('./utils/amount-from-labels'); const amountFromLabels = require('./utils/amount-from-labels');
const kindFromLabels = require('./utils/kind-from-labels'); const kindFromLabels = require('./utils/kind-from-labels');
@ -61,7 +61,7 @@ module.exports = async function(robot, kredits) {
robot.logger.debug(`[hubot-kredits] contribution attributes:`); robot.logger.debug(`[hubot-kredits] contribution attributes:`);
robot.logger.debug(util.inspect(contributionAttr, { depth: 1, colors: true })); robot.logger.debug(util.inspect(contributionAttr, { depth: 1, colors: true }));
return Contribution.add(contributionAttr).catch(error => { return Contribution.addContribution(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 tried to add a contribution for ${githubUser} for ${url}, but I encountered an error when submitting the tx:`);
messageRoom(error.message); messageRoom(error.message);
@ -184,10 +184,11 @@ module.exports = async function(robot, kredits) {
if (process.env.KREDITS_GITHUB_KEY && process.env.KREDITS_GITHUB_SECRET) { if (process.env.KREDITS_GITHUB_KEY && process.env.KREDITS_GITHUB_SECRET) {
const grantConfig = { const grantConfig = {
defaults: { defaults: {
origin: (process.env.KREDITS_GRANT_ORIGIN || 'http://localhost:8888'), protocol: (process.env.KREDITS_GRANT_PROTOCOL || "http"),
prefix: '/kredits/signup/connect', host: (process.env.KREDITS_GRANT_HOST || 'localhost:8888'),
transport: 'session', transport: 'session',
response: 'tokens', response: 'tokens',
path: '/kredits/signup'
}, },
github: { github: {
key: process.env.KREDITS_GITHUB_KEY, key: process.env.KREDITS_GITHUB_KEY,
@ -202,7 +203,7 @@ module.exports = async function(robot, kredits) {
saveUninitialized: false saveUninitialized: false
})); }));
robot.router.use(grant(grantConfig)); robot.router.use('/kredits/signup', grant(grantConfig));
robot.router.get('/kredits/signup/github', async (req, res) => { robot.router.get('/kredits/signup/github', async (req, res) => {
const access_token = req.session.grant.response.access_token; const access_token = req.session.grant.response.access_token;

View File

@ -48,7 +48,7 @@ module.exports = async function(robot, kredits) {
kind: 'docs' kind: 'docs'
}; };
return Contribution.add(contribution).catch(error => { return Contribution.addContribution(contribution).catch(error => {
robot.logger.error(`[hubot-kredits] Adding contribution failed:`, error); robot.logger.error(`[hubot-kredits] Adding contribution failed:`, error);
}); });
}).catch(() => { }).catch(() => {
@ -112,17 +112,20 @@ module.exports = async function(robot, kredits) {
} }
async function createContributions (changes) { async function createContributions (changes) {
let promises = [];
for (const user of Object.keys(changes)) { for (const user of Object.keys(changes)) {
await createContributionForUserChanges(user, changes[user]); await createContributionForUserChanges(user, changes[user]);
await sleep(60000); await sleep(60000);
} }
return Promise.resolve();
} }
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;
@ -141,8 +144,9 @@ module.exports = async function(robot, kredits) {
const dateNow = new Date(); const dateNow = new Date();
const dateYesterday = dateNow.setDate(dateNow.getDate() - 1); const dateYesterday = dateNow.setDate(dateNow.getDate() - 1);
const date = (new Date(dateYesterday)).toISOString().split('T')[0]; 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) {

View File

@ -1,9 +1,5 @@
const fetch = require('node-fetch'); const fetch = require('node-fetch');
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) {
@ -12,11 +8,14 @@ module.exports = async function(robot, kredits) {
const { Contributor, Contribution } = kredits; const { Contributor, Contribution } = kredits;
const kreditsContributionAmount = process.env.KREDITS_ZOOM_CONTRIBUTION_AMOUNT || 500; const kreditsContributionAmount = 500;
const kreditsContributionKind = 'community'; const kreditsContributionKind = 'community';
const zoomAccessToken = process.env.KREDITS_ZOOM_JWT; const zoomAccessToken = process.env.KREDITS_ZOOM_JWT;
const walletTransactionCount = await kredits.provider.getTransactionCount(kredits.signer.address);
let nonce = walletTransactionCount;
async function createContributionFor (displayName, meeting) { async function createContributionFor (displayName, meeting) {
const contributor = await getContributorByZoomDisplayName(displayName); const contributor = await getContributorByZoomDisplayName(displayName);
@ -36,7 +35,7 @@ module.exports = async function(robot, kredits) {
time: meeting.end_time.split('T')[1] time: meeting.end_time.split('T')[1]
} }
return Contribution.add(contribution) return Contribution.add(contribution, { nonce: nonce++ })
.then(tx => { .then(tx => {
robot.logger.info(`[hubot-kredits] Contribution created: ${tx.hash}`); robot.logger.info(`[hubot-kredits] Contribution created: ${tx.hash}`);
}) })
@ -70,16 +69,16 @@ module.exports = async function(robot, kredits) {
async function handleZoomMeetingEnded(data) { async function handleZoomMeetingEnded(data) {
const meetingDetails = await getMeetingDetails(data.uuid); const meetingDetails = await getMeetingDetails(data.uuid);
const participants = await getMeetingParticipants(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) { if (meetingDetails.duration < 15 || meetingDetails.participants_count < 3) {
robot.logger.info(`[hubot-kredits] Ignoring zoom call ${data.uuid} (duration: ${meetingDetails.duration}, participants_count: ${meetingDetails.participants_count})`); robot.logger.info(`[hubot-kredits] Ignoring zoom call ${data.uuid} (duration: ${meetingDetails.duration}, participants_count: ${meetingDetails.participants_count})`);
return; return;
} }
const names = Array.from(new Set(participants.map(p => p.name)));
for (const displayName of names) { for (const displayName of names) {
await createContributionFor(displayName, meetingDetails); 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.
}; };
} }
@ -89,11 +88,7 @@ module.exports = async function(robot, kredits) {
const payload = data.payload; const payload = data.payload;
const object = payload.object; const object = payload.object;
if (eventName === 'meeting.ended') {
if (eventName === 'meeting.ended' && (
!process.env.KREDITS_ZOOM_MEETING_WHITELIST ||
process.env.KREDITS_ZOOM_MEETING_WHITELIST.split(',').includes(object.id)
)) {
handleZoomMeetingEnded(object); handleZoomMeetingEnded(object);
} }

7122
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,32 +1,27 @@
{ {
"name": "@kredits/hubot-kredits", "name": "hubot-kredits",
"version": "4.1.0", "version": "3.5.0",
"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",
"@kredits/contracts": "^7.3.0",
"axios": "^1.7.9",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "eth-provider": "^0.2.2",
"eth-provider": "^0.13.6", "ethers": "^4.0.27",
"ethers": "^5.0.5", "express": "^4.17.1",
"express": "^4.18.2", "express-session": "^1.16.2",
"express-session": "^1.17.3", "grant-express": "^4.6.1",
"grant-express": "^5.4.8",
"group-array": "^1.0.0", "group-array": "^1.0.0",
"kosmos-schemas": "^2.2.1", "kosmos-schemas": "^1.1.2",
"kredits-contracts": "^5.4.0",
"node-cron": "^2.0.3", "node-cron": "^2.0.3",
"node-fetch": "^2.6.12", "node-fetch": "^2.3.0",
"prompt": "^1.3.0", "prompt": "^1.0.0"
"yargs": "^17.7.2"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -40,8 +35,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,
});
}
}
}
);
});