Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[discuss] Add support for contribution and contributor creation #1

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ Tools for using Kredits with GitHub.
## Features

* Check open pull requests for kredits labels
* Create contributions from merged pull requests
* Add contributor profiles for GitHub users

## Setup

Expand Down
20 changes: 20 additions & 0 deletions config/defaults.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
module.exports = {
address: process.env.DAO_ADDRESS,
ethRpcUrl: process.env.ETH_RPC_URL,
ethNetwork: process.env.ETH_NETWORK || 'rinkeby',
apmDomain: process.env.APM_DOMAIN || 'open.aragonpm.eth',
coinName: 'Kredits',
coinSymbol: 'K',
claimedLabel: 'kredits-claimed',
amountLabelRegex: 'kredits-\\d',
amounts: {
'kredits-1': 500,
'kredits-2': 1500,
'kredits-3': 5000
},
ipfsConfig: {
host: process.env.IPFS_API_HOST || 'localhost',
port: process.env.IPFS_API_PORT || '5001',
protocol: process.env.IPFS_API_PROTOCOL || 'http'
}
};
184 changes: 146 additions & 38 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,56 @@
// Checks API example
// See: https://developer.github.com/v3/checks/ to learn more
const RSVP = require('rsvp');
const Kredits = require('kredits-contracts');
const ethers = require('ethers');

const PullRequest = require('./lib/pull-request');
const claimPullRequest = require('./lib/claim-pull-request');
const addContributor = require('./lib/add-contributor');
const handlePullRequestChange = require('./lib/handle-pull-request-change');

const defaultConfig = require('./config/defaults');

let wallet;
if (process.env.WALLET_PRIVATE_KEY) {
wallet = new ethers.Wallet(process.env.WALLET_PRIVATE_KEY);
} else {
console.log('No wallet could not be loaded. Running as a read only bot to check labels.');
}

function getConfig (context) {
let repo = context.repo();
return context.github.repos.getContents({
owner: repo.owner,
repo: repo.repo,
path: '.github/kredits.json'
})
.then(configFile => {
let content = Buffer.from(configFile.data.content, 'base64').toString();
let config = JSON.parse(content);
return Object.assign({}, defaultConfig, config);
})
.catch(e => {
console.log('Error loading config', e.message);
return defaultConfig;
});
}

function getKredits (config) {
return Kredits.for(
{ rpcUrl: config.ethRpcUrl, network: config.ethNetwork, wallet: wallet },
{
addresses: { Kernel: config.address },
apm: config.apmDomain,
ipfsConfig: config.ipfsConfig
}
).init();
}

/**
* This is the main entrypoint to your Probot app
* @param {import('probot').Application} app
*/
module.exports = app => {
if (wallet) {
wallet.getAddress().then(address => {
app.log(`Bot address: ${address}`);
});
}

app.on([
'pull_request.opened',
Expand All @@ -15,41 +60,104 @@ module.exports = app => {
'pull_request.synchronize'
], handlePullRequestChange)

async function handlePullRequestChange (context) {
const { action, pull_request: pr, repository: repo } = context.payload
const hasKreditsLabel = !!pr.labels.find(l => l.name.match(/^kredits-\d$/))

try { await setStatus(hasKreditsLabel, context) }
catch (e) { console.log(e) }
}
app.on(['issue_comment.created'], async (context) => {
if (!wallet) { app.log('No wallet configured'); return; }
if (context.isBot) { return; }
// check if kredits bot is mentioned
const commandMatch = context.payload.comment.body.match(/^\/([\w]+)\b *(.*)?$/m);
if (!commandMatch || commandMatch[1].toLowerCase() != 'kredits') {
return;
}
const command = commandMatch[2];

function setStatus (hasKreditsLabel, context) {
const pullRequest = context.payload.pull_request

const checkOptions = {
name: "Kredits",
head_branch: '', // workaround for https://github.com/octokit/rest.js/issues/874
head_sha: pullRequest.head.sha,
status: 'in_progress',
started_at: (new Date()).toISOString(),
output: {
title: 'Kredits label missing',
summary: 'No kredits label assigned. Please add one for this check to pass.',
text: 'This project rewards contributions with Kosmos Kredits. In order to determine the amount, the bot looks for a label of `kredits-1` (small contribution), `kredits-2` (medium-size contribution), or `kredits-3` (large contribution).'
}
const config = await getConfig(context);
// check if a DAO is configured
if (!config.address) {
console.log('No DAO address found in config');
return;
}
const kredits = await getKredits(config);
const pullRequest = await context.github.pulls.get({
owner: context.payload.repository.owner.login,
repo: context.payload.repository.name,
pull_number: context.payload.issue.number
});
const pull = new PullRequest({
data: pullRequest.data,
repository: context.payload.repository,
config: config
});

if (hasKreditsLabel) {
checkOptions.status = 'completed'
checkOptions.conclusion = 'success'
checkOptions.completed_at = new Date()
checkOptions.output.title = 'Kredits label assigned'
checkOptions.output.summary = ''
const accountMatch = command.match(/0x[a-fA-F0-9]{40}/g)
if (command.split(' ')[0] === 'claim') {
claimPullRequest({ kredits, config, pull, context })
.then(created => {
if (created) {
app.log.info('Contribution created', context.payload.pull_request.url);
}
})
.catch(e => {
app.log.error(e);
});
} else if (accountMatch) {
const account = accountMatch[0];
const commentAuthor = await context.github.users.getByUsername({ username: context.payload.sender.login }); // get full profile
const contributorAttr = {
name: commentAuthor.data.name,
github_username: commentAuthor.data.login,
github_uid: commentAuthor.data.id,
url: commentAuthor.data.blog || commentAuthor.data.html_url,
kind: 'person',
account: account
};
addContributor(kredits, contributorAttr).then(contributor => {
context.github.issues.createComment(context.issue({
body: `Great @${commentAuthor.data.login}, your profile is all set.`
}));

// check if we now have all profiles, and if so send the kredits
let contributorPromises = {};
pull.recipients.forEach(username => {
contributorPromises[username] = kredits.Contributor.findByAccount({ username, site: 'github.com' });
});
RSVP.hash(contributorPromises).then(contributors => {
const missingContributors = Object.keys(contributors).filter(c => contributors[c] === undefined);
if (missingContributors.length === 0) {
claimPullRequest({ kredits, config, pull, context });
}
});
});
}
});

return context.github.checks.create(context.repo(checkOptions))
}

// For more information on building apps:
// https://probot.github.io/docs/
}
app.on('pull_request.closed', async context => {
if (!wallet) { app.log.debug('No wallet configured'); return; }
if (!context.payload.pull_request.merged) {
app.log.debug('Pull request not merged ', context.payload.pull_request.url);
return true;
}
const config = await getConfig(context);
if (!config.address) {
app.log.info('No DAO address found in config', context.payload.repository.full_name);
return;
}
const kredits = await getKredits(config);

const pull = new PullRequest({
data: context.payload.pull_request,
repository: context.payload.repository,
config: config,
});
app.log.info('Claiming PR', context.payload.pull_request.url);
claimPullRequest({ kredits, config, pull, context })
.then(created => {
if (created) {
app.log.info('Contribution created', context.payload.pull_request.url);
}
})
.catch(e => {
app.log.error(e);
});
});
};
12 changes: 12 additions & 0 deletions lib/add-contribution.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
module.exports = function (kredits, contributor, contribution) {
const contributionAttr = Object.assign({}, contribution, {
contributorId: contributor.id,
contributorIpfsHash: contributor.ipfsHash,
kind: 'dev'
});
return kredits.Contribution.addContribution(contributionAttr, {gasLimit: 600000})
.then(transaction => {
console.log(`Contribution added for contributor #${contributor.id}: ${transaction.hash}`);
return transaction;
});
}
23 changes: 23 additions & 0 deletions lib/add-contributor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@

module.exports = function (kredits, contributorAttr) {
return kredits.Contributor.findByAccount({
username: contributorAttr.github_username,
site: 'github.com' })
.then(contributor => {
if(contributor) {
return Promise.resolve(); // we already have that contributor
} else {
return kredits.Contributor.add(contributorAttr, {gasLimit: 400000})
.then(transaction => {
console.log('Contributor added', transaction.hash);
return transaction.wait()
.then(confirmedTx => {
return kredits.Contributor.findByAccount({
site: 'github.com',
username: contributorAttr.github_username
});
});
});
}
});
}
36 changes: 36 additions & 0 deletions lib/claim-pull-request.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
const addContributionFor = require('./add-contribution');
const RSVP = require('rsvp');

module.exports = function (options) {
const { kredits, config, context, pull } = options;

if (!pull.valid) {
return Promise.reject(new Error(`Pull request invalid: amount=${pull.amount} claimed=${pull.claimed}`));
}

let contributorPromises = {};
pull.recipients.forEach(username => {
contributorPromises[username] = kredits.Contributor.findByAccount({ username: username, site: 'github.com' });
});
return RSVP.hash(contributorPromises).then(contributors => {
const missingContributors = Object.keys(contributors).filter(c => contributors[c] === undefined);
if (missingContributors.length > 0) {
context.github.issues.createComment(context.issue({
body: `I tried to send you ${pull.amount}${config.coinSymbol} but I am missing the contributor details of ${missingContributors.join(', ')}.
Please reply with \`/kredits [your ethereum address]\` to create a contributor profile.`
}));
return false;
} else {
const addPromises = Object.values(contributors).map(c => addContributionFor(kredits, c, pull.contributionAttributes));
Promise.all(addPromises).then(transactions => {
context.github.issues.createComment(context.issue({
body: `Thanks for your contribution! ${pull.amount}${config.coinSymbol} are on the way to @${Object.keys(contributors).join(', ')}.`
}));
if (config.claimedLabel) {
context.github.issues.addLabels(context.issue({ labels: [config.claimedLabel] }));
}
return true;
});
}
});
};
34 changes: 34 additions & 0 deletions lib/handle-pull-request-change.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
function setStatus (hasKreditsLabel, context) {
const pullRequest = context.payload.pull_request

const checkOptions = {
name: "Kredits",
head_branch: '', // workaround for https://github.com/octokit/rest.js/issues/874
head_sha: pullRequest.head.sha,
status: 'in_progress',
started_at: (new Date()).toISOString(),
output: {
title: 'Kredits label missing',
summary: 'No kredits label assigned. Please add one for this check to pass.',
text: 'This project rewards contributions with Kosmos Kredits. In order to determine the amount, the bot looks for a label of `kredits-1` (small contribution), `kredits-2` (medium-size contribution), or `kredits-3` (large contribution).'
}
}

if (hasKreditsLabel) {
checkOptions.status = 'completed'
checkOptions.conclusion = 'success'
checkOptions.completed_at = new Date()
checkOptions.output.title = 'Kredits label assigned'
checkOptions.output.summary = ''
}

return context.github.checks.create(context.repo(checkOptions))
}

module.exports = async function (context) {
const { action, pull_request: pr, repository: repo } = context.payload
const hasKreditsLabel = !!pr.labels.find(l => l.name.match(/^kredits-\d$/))

try { await setStatus(hasKreditsLabel, context) }
catch (e) { console.log(e) }
}
Loading