Compare commits

...

54 Commits

Author SHA1 Message Date
174bc24421 Merge pull request 'Propose kredits for PR reviews' (#71) from feature/review-kredits into master
Reviewed-on: #71
Reviewed-by: slvrbckt <slvrbckt@noreply.kosmos.org>
2025-02-10 10:57:45 +00:00
bd781e1937
Formatting, explicit async 2025-02-09 13:18:48 +04:00
5fd7e50893
Create contributions for reviews
* Improve the description to include number of PRs and start/end date
* Set the contribution date to 00:00 on the day after the end date
* Add a sub-kind to the details property so it's easy to identify
  reviews
2025-02-09 12:55:27 +04:00
c93ddaebee
WIP Adapt script to latest RSK kredits code 2025-01-26 16:06:53 -05:00
94dd9b5ef7
WIP Merge galfert's changes from the new repo 2025-01-23 16:10:18 -05:00
82fb429f44
Update repos 2025-01-23 16:10:09 -05:00
Râu Cao
dd89f96bee
4.1.0 2023-08-14 17:31:10 +02:00
Râu Cao
90bb475f0d
Merge pull request #70 from 67P/chore/update_dependencies
Update dependencies (incl. new contracts)
2023-08-14 17:23:46 +02:00
Râu Cao
dabd997004
Update dependencies (incl. new contracts)
Needs some code updates for grant v5
2023-08-14 17:21:10 +02:00
Râu Cao
c196ded52c
Update lockfile 2023-01-19 14:59:34 +08:00
Râu Cao
f74ccdc7ff
Remove obsolete code 2023-01-19 14:59:01 +08:00
Râu Cao
86dd3b6979
4.0.2 2022-11-02 19:10:42 +01:00
Râu Cao
948e536327
Update npm badge 2022-11-02 19:10:29 +01:00
Greg Karékinian
b6b91bef5b
Merge pull request #68 from 67P/chore/kredits_upgrade
Update and adapt for new kredits contracts release
2022-11-02 19:09:09 +01:00
Râu Cao
48fa2e937b
4.0.1 2022-11-02 18:51:09 +01:00
Râu Cao
de5ee5e323
Publish as @kredits/hubot-kredits 2022-11-02 18:50:47 +01:00
Râu Cao
7fb5fb747d
Be less stringent with contracts version 2022-11-02 18:38:29 +01:00
Râu Cao
70a74ba5fb
4.0.0 2022-11-02 18:32:19 +01:00
Râu Cao
1ab5ae55c6
Use new @kredits/contracts release 2022-11-02 18:30:57 +01:00
Râu Cao
710bd90172
Update and adapt for new kredits contracts release 2022-10-31 13:01:29 +01:00
Greg Karékinian
7db7a83a34
Merge pull request #67 from 67P/bugfix/fix_wallet_loading
Fix wallet loading in review-kredits script
2021-03-30 14:06:30 +02:00
2e840bcb2b
Fix wallet loading in review-kredits script
Requiring the wallet JSON file parses it, so we have to read it directly
from the filesystem instead.
2021-03-30 13:51:34 +02:00
09637121be
Merge pull request #66 from 67P/feature/49-create_review_contributions
Create contributions for PR reviews
2021-03-29 10:43:22 +02:00
86f614ceee
Create contributions for PR reviews
Refs #49
2021-01-29 12:33:38 +01:00
c27fefcfdc
Merge pull request #64 from 67P/feature/49-code_review_kredits
Create contributions for pull request reviews
2021-01-29 11:41:00 +01:00
cbfd25e880
Exit with error message when API token is missing 2021-01-29 11:06:31 +01:00
8b82b8b159
Add timeframe to contribution description 2021-01-29 11:05:45 +01:00
084dec58a9
Remove unused variable 2021-01-29 11:01:46 +01:00
9a4e465608
Remove unused variable 2021-01-28 13:05:36 +01:00
93d7c8944b
Collect contribution data for pull request reviews 2021-01-12 15:32:06 +01:00
ad0e34b990
3.8.0 2020-10-29 15:15:10 +01:00
063a9c6b37
Merge pull request #63 from 67P/bugfix/mediawiki_accident
Fix mediawiki integration
2020-10-29 15:13:12 +01:00
f05436e9b9
Fix mediawiki integration
Accidentally deleted a line in a recent PR, and it slipped through the
review.
2020-10-29 15:12:50 +01:00
e305643f69
Merge pull request #61 from 67P/chore/replace_deprecated_contract_calls
Replace deprecated contract API calls
2020-10-29 14:13:36 +01:00
82a003ffeb
Merge pull request #62 from 67P/feature/wiki_changes
Only create small automatic contributions for wiki edits
2020-10-29 14:10:33 +01:00
fc0c113997
Only create small automatic contributions for wiki edits
We decided that it's too difficult for a machine to gauge the meaning
and value of wiki edits by line numbers, so automatic kredits are now
always a small contributions. Until we have new tools for larger wiki
contributions (e.g. mediawiki tags), we can create manual contributions
for those.
2020-10-29 12:56:49 +01:00
5c6540580b
Replace deprecated contract API calls
Use the new method.
2020-10-29 12:07:42 +01:00
078f78417c 3.7.0 2020-07-18 13:06:48 +02:00
d870099059 Update to latest ethers.js patch release 2020-07-18 13:06:09 +02:00
b7482f2468 package-lock 2020-07-18 13:04:12 +02:00
94c256e3d9
Merge pull request #60 from 67P/dependabot/npm_and_yarn/lodash-4.17.19
Bump lodash from 4.17.15 to 4.17.19
2020-07-18 12:59:15 +02:00
3b382eadb2
Merge pull request #59 from 67P/ethers-nonce-manager
Use new ethers.js NonceManager to handle transaction nonces
2020-07-18 12:58:45 +02:00
ec63980cd3 Use kredits-contracts 6.0.0 2020-07-17 13:50:37 +02:00
dependabot[bot]
2b86f37fcb
Bump lodash from 4.17.15 to 4.17.19
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.15 to 4.17.19.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.15...4.17.19)

Signed-off-by: dependabot[bot] <support@github.com>
2020-07-16 03:28:48 +00:00
a8e29f2197 Use ethers5 branch of kredits-contracts 2020-06-27 18:38:22 +02:00
b7ff55929c Use new ethers.js NonceManager to handle transaction nonces
So far we have failed to globally handle the transaction nonces.
The new ethers.js v5 comes with a NonceManager that helps us handling
transaction nonces and automatically increases the nonce for each
transaction.
2020-06-27 18:24:44 +02:00
35f6acc150
3.6.0 2020-05-22 12:10:24 +02:00
095a1e0004
Merge pull request #56 from 67P/feature/zoom-meeting-whitelist
Add zoom meeting whitelist
2020-05-18 10:43:25 +02:00
95290a7715
Apply suggestions from code review
Co-authored-by: Sebastian Kippe <sebastian@kip.pe>
2020-05-14 12:13:38 +02:00
fca017c61a Add readme for zoom integration 2020-05-14 10:38:51 +02:00
fb1a471303 Make kredits amount for zoom calls configurable
defaults to 500 - a general small contribution
2020-05-14 10:33:13 +02:00
d82e2e9256 Revert "Update integrations/zoom.js"
This reverts commit 634dc207e686764e55c7dc520a9c4e0ccf60c122.
2020-05-14 10:00:00 +02:00
634dc207e6
Update integrations/zoom.js
Co-authored-by: Sebastian Kippe <sebastian@kip.pe>
2020-05-14 09:45:42 +02:00
6fd3989118 Add zoom meeting whitelist
This allows to only record meetings for certain whitelisted meeting ids
2020-04-30 16:19:49 +02:00
14 changed files with 6030 additions and 1778 deletions

2
.env.example Normal file
View File

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

1
.gitignore vendored
View File

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

View File

@ -1,4 +1,4 @@
[![npm](https://img.shields.io/npm/v/hubot-kredits.svg)](https://www.npmjs.com/package/hubot-kredits)
[![npm](https://img.shields.io/npm/v/@kredits/hubot-kredits.svg)](https://www.npmjs.com/package/@kredits/hubot-kredits)
# Hubot Kredits
@ -10,15 +10,20 @@ contributions.
## Setup
### Ethereum wallet
### 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 ETH to interact with the contracts.
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]:
@ -41,11 +46,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_ROOM` | The bot will talk to you in this room |
| `KREDITS_WALLET_PATH` | Path to an Etherum wallet JSON file (default: `./wallet.json`) |
| `KREDITS_WALLET_PATH` | Path to an wallet JSON file (default: `./wallet.json`) |
| `KREDITS_WALLET_PASSWORD` | Wallet password |
| `KREDITS_PROVIDER_URL` | Ethereum JSON-RPC URL (default: `http://localhost:7545`) |
| `KREDITS_PROVIDER_URL` | JSON-RPC URL of a blockchain node (default: `http://localhost:7545`) |
| `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_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") |
@ -116,3 +120,29 @@ wiki's API on its own.
[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,13 +1,11 @@
const fs = require('fs');
const util = require('util');
const fetch = require('node-fetch');
const ethers = require('ethers');
const Kredits = require('kredits-contracts');
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;
const daoAddress = process.env.KREDITS_DAO_ADDRESS;
const providerUrl = process.env.KREDITS_PROVIDER_URL || 'http://localhost:7545';
const ipfsConfig = {
host: process.env.IPFS_API_HOST || 'localhost',
@ -37,22 +35,16 @@ module.exports = async function(robot) {
// Ethereum provider/node setup
//
let ethProvider;
if (providerUrl) {
ethProvider = new ethers.providers.JsonRpcProvider(providerUrl);
} else {
ethProvider = new ethers.getDefaultProvider('rinkeby');
}
const signer = wallet.connect(ethProvider);
robot.logger.info('[hubot-kredits] 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 };
if (daoAddress) {
opts.addresses = { Kernel: daoAddress };
}
let kredits;
try {
@ -62,8 +54,8 @@ module.exports = async function(robot) {
process.exit(1);
}
const Contributor = kredits.Contributor;
const Proposal = kredits.Proposal;
const Contribution = kredits.Contribution;
// TODO const Reimbursement = kredits.Reimbursement;
robot.logger.info('[hubot-kredits] Wallet address: ' + wallet.address);
@ -72,9 +64,9 @@ module.exports = async function(robot) {
//
ethProvider.getBalance(wallet.address).then(balance => {
robot.logger.info('[hubot-kredits] Wallet balance: ' + ethers.utils.formatEther(balance) + 'ETH');
robot.logger.info('[hubot-kredits] Wallet balance: ' + ethers.utils.formatEther(balance) + ' RBTC');
if (balance.lt(ethers.utils.parseEther('0.0001'))) {
messageRoom(`Yo gang, I\'m broke! Please drop me some ETH to ${wallet.address}. kthxbai.`);
messageRoom(`Yo gang, I\'m broke! Please send some RBTC to ${wallet.address}. kthxbai.`);
}
});
@ -82,30 +74,9 @@ module.exports = async function(robot) {
// Robot chat commands/interaction
//
robot.respond(/got ETH\??/i, res => {
robot.respond(/got RBTC\??/i, res => {
ethProvider.getBalance(wallet.address).then((balance) => {
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');
res.send(`My wallet contains ${ethers.utils.formatEther(balance)} RBTC`);
});
});
@ -114,31 +85,23 @@ module.exports = async function(robot) {
//
function watchContractEvents() {
ethProvider.getBlockNumber().then((blockNumber) => {
ethProvider.getBlockNumber().then(blockNumber => {
// current block is the last mined one, thus we check from the next
// mined one onwards to prevent getting previous events
let nextBlock = blockNumber + 1;
robot.logger.debug(`[hubot-kredits] Watching events from block ${nextBlock} onward`);
ethProvider.resetEventsBlock(nextBlock);
Proposal.on('ProposalCreated', handleProposalCreated);
// TODO handle all known events (that make sense here)
// Contribution.on('ContributorAdded', handleContributorAdded);
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) {
Contributor.getById(contributorId).then((contributor) => {
Contribution.getById(contributionId).then((contribution) => {
robot.logger.debug(`[hubot-kredits] Contribution #${contribution.id} added (${contribution.description})`);
Contributor.getById(contributorId).then(_ => {
Contribution.getById(contributionId).then(contribution => {
robot.logger.debug(`[hubot-kredits] Contribution #${contribution.id} added (${amount} kredits for "${contribution.description}")`);
});
});
}

View File

@ -1,5 +1,4 @@
const util = require('util');
const fetch = require('node-fetch');
const amountFromLabels = require('./utils/amount-from-labels');
const kindFromLabels = require('./utils/kind-from-labels');
@ -56,7 +55,7 @@ module.exports = async function(robot, kredits) {
robot.logger.debug(`[hubot-kredits] contribution attributes:`);
robot.logger.debug(util.inspect(contributionAttr, { depth: 1, colors: true }));
return Contribution.addContribution(contributionAttr).catch(error => {
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);

View File

@ -1,8 +1,8 @@
const util = require('util');
const fetch = require('node-fetch');
const session = require('express-session');
const grant = require('grant-express');
const cors = require('cors');
const grant = require('grant').express();
const amountFromLabels = require('./utils/amount-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(util.inspect(contributionAttr, { depth: 1, colors: true }));
return Contribution.addContribution(contributionAttr).catch(error => {
return Contribution.add(contributionAttr).catch(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(error.message);
@ -184,11 +184,10 @@ module.exports = async function(robot, kredits) {
if (process.env.KREDITS_GITHUB_KEY && process.env.KREDITS_GITHUB_SECRET) {
const grantConfig = {
defaults: {
protocol: (process.env.KREDITS_GRANT_PROTOCOL || "http"),
host: (process.env.KREDITS_GRANT_HOST || 'localhost:8888'),
origin: (process.env.KREDITS_GRANT_ORIGIN || 'http://localhost:8888'),
prefix: '/kredits/signup/connect',
transport: 'session',
response: 'tokens',
path: '/kredits/signup'
},
github: {
key: process.env.KREDITS_GITHUB_KEY,
@ -203,7 +202,7 @@ module.exports = async function(robot, kredits) {
saveUninitialized: false
}));
robot.router.use('/kredits/signup', grant(grantConfig));
robot.router.use(grant(grantConfig));
robot.router.get('/kredits/signup/github', async (req, res) => {
const access_token = req.session.grant.response.access_token;

View File

@ -48,7 +48,7 @@ module.exports = async function(robot, kredits) {
kind: 'docs'
};
return Contribution.addContribution(contribution).catch(error => {
return Contribution.add(contribution).catch(error => {
robot.logger.error(`[hubot-kredits] Adding contribution failed:`, error);
});
}).catch(() => {
@ -112,20 +112,17 @@ module.exports = async function(robot, kredits) {
}
async function createContributions (changes) {
let promises = [];
for (const user of Object.keys(changes)) {
await createContributionForUserChanges(user, changes[user]);
await sleep(60000);
}
return Promise.resolve();
}
function pageTitlesFromChanges(changes) {
return [...new Set(changes.map(c => `"${c.title}"`))].join(', ');
}
// Currently not used
function calculateAmountForChanges(details) {
let amount;
@ -144,9 +141,8 @@ module.exports = async function(robot, kredits) {
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 amount = calculateAmountForChanges(details);
const amount = 500;
let desc = `Added ${details.charsAdded} characters of text.`;
if (details.pagesChanged.length > 0) {

View File

@ -1,5 +1,9 @@
const fetch = require('node-fetch');
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
module.exports = async function(robot, kredits) {
function messageRoom(message) {
@ -8,14 +12,11 @@ module.exports = async function(robot, kredits) {
const { Contributor, Contribution } = kredits;
const kreditsContributionAmount = 500;
const kreditsContributionAmount = process.env.KREDITS_ZOOM_CONTRIBUTION_AMOUNT || 500;
const kreditsContributionKind = 'community';
const zoomAccessToken = process.env.KREDITS_ZOOM_JWT;
const walletTransactionCount = await kredits.provider.getTransactionCount(kredits.signer.address);
let nonce = walletTransactionCount;
async function createContributionFor (displayName, meeting) {
const contributor = await getContributorByZoomDisplayName(displayName);
@ -35,7 +36,7 @@ module.exports = async function(robot, kredits) {
time: meeting.end_time.split('T')[1]
}
return Contribution.add(contribution, { nonce: nonce++ })
return Contribution.add(contribution)
.then(tx => {
robot.logger.info(`[hubot-kredits] Contribution created: ${tx.hash}`);
})
@ -78,6 +79,7 @@ module.exports = async function(robot, kredits) {
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.
};
}
@ -87,7 +89,11 @@ module.exports = async function(robot, kredits) {
const payload = data.payload;
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);
}

7150
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -0,0 +1,95 @@
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

@ -0,0 +1,96 @@
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;
}
}

25
scripts/repos.json Normal file
View File

@ -0,0 +1,25 @@
{
"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"
]
}

246
scripts/review-kredits.js Executable file
View File

@ -0,0 +1,246 @@
#!/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,
});
}
}
}
);
});