Compare commits

...

199 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
41f5aef460
3.5.1 2020-04-16 21:43:48 +02:00
c121713a13
Merge pull request #54 from 67P/bugfix/ignore-small-meetings
Ignore meetings that have less than 3 unique participants
2020-04-16 21:17:16 +02:00
e10dd4abc3 Ignore meetings that have less than 3 unique participants
zoom's participants_count is not unique and the same person can be counted
multiple times.
We need to check for unique names.
2020-04-16 18:07:17 +02:00
e823797ee2
3.5.0 2020-04-16 17:40:46 +02:00
7d3c2cae19
Merge branch 'feature/zoom' 2020-04-16 17:34:31 +02:00
960dcb55de
Moar await 2020-04-16 17:33:53 +02:00
60ed697460 Add comment when tx is undefined
We createContributionFor() simply returns if no contributor is found.
2020-04-16 17:17:40 +02:00
7f653f23ce typo 2020-04-16 17:08:26 +02:00
98ff61ab0a Add handling of missing zoom profiles 2020-04-16 17:03:03 +02:00
e7f8723f6e Make sure zoom participants are unique
to make sure we only create one contribution per participants
2020-04-16 16:36:32 +02:00
164782bd25
Only load Zoom integration when JWT configured 2020-04-16 12:18:52 +02:00
8f961bb102
Apply suggestions from code review
Co-Authored-By: Sebastian Kippe <sebastian@kip.pe>
2020-04-16 12:08:50 +02:00
c4ef8de018
Nicer log messages
Co-Authored-By: Sebastian Kippe <sebastian@kip.pe>
2020-04-16 12:08:06 +02:00
110c4384e0 Autoload zoom integration 2020-04-15 21:51:02 +02:00
70ea031b31 Zoom integration using the JWT API 2020-04-15 21:29:21 +02:00
ab8d043593
Merge pull request #52 from 67P/dependabot/npm_and_yarn/kind-of-6.0.3
Bump kind-of from 6.0.2 to 6.0.3
2020-03-31 19:56:23 -05:00
dependabot[bot]
8b5c2a3274
Bump kind-of from 6.0.2 to 6.0.3
Bumps [kind-of](https://github.com/jonschlinkert/kind-of) from 6.0.2 to 6.0.3.
- [Release notes](https://github.com/jonschlinkert/kind-of/releases)
- [Changelog](https://github.com/jonschlinkert/kind-of/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jonschlinkert/kind-of/compare/6.0.2...6.0.3)

Signed-off-by: dependabot[bot] <support@github.com>
2020-04-01 00:54:28 +00:00
5cc0116163 Skeleton of the zoom integration
using the new zoom API
2020-02-27 15:48:55 +01:00
708f0b6622
Add release-drafter config 2019-09-01 17:06:38 +02:00
f20a8327af
3.4.1 2019-09-01 17:05:42 +02:00
571de43aa8
Merge pull request #47 from 67P/chore/fix_warnings
Fix warnings from express session
2019-09-01 17:05:22 +02:00
50002194c5
Fix warnings from express session 2019-09-01 17:04:19 +02:00
cecc632620
Merge pull request #46 from 67P/bugfix/signup_issue
Fix critical bug in signup code
2019-09-01 17:03:06 +02:00
6972d6c88e
Fix critical bug in signup code
Variable scope/assignment issue
2019-09-01 17:00:56 +02:00
157d2dee49
3.4.0 2019-08-31 13:42:50 +02:00
590ca961a6
Merge pull request #45 from 67P/docs/github-signup
Add GitHub signup oracle documentation
2019-08-31 13:26:23 +02:00
6476fe5c88
Improve signup docs 2019-08-31 13:23:11 +02:00
101e35622a Add GitHub signup oracle documentation 2019-08-31 12:52:36 +02:00
afa67e5ac7 Prefix all configuration environment variables
The convention here is to prefix all Kredit related configuration
variables with `KREDITS_`
2019-08-31 12:51:44 +02:00
cbbc6c359e
Merge pull request #41 from 67P/feature/github-signup
Add GitHub signup oracle
2019-08-28 15:56:54 +02:00
c16ea51769 Error handling for fetching Github access token 2019-08-27 18:35:40 +02:00
fc49213b01
Update contracts min version 2019-08-27 18:23:22 +02:00
32dc6a7358
GitHub signup fixes
* Don't fail when profile has no name set
* Use scope that can only read public profile information (no scope)
* Better error handling
2019-08-27 18:22:36 +02:00
53dee63a76 Add env variable for session secret 2019-08-27 15:30:30 +02:00
97ec6ce9bc Add KREDITS_DAO_ADDRESS to readme 2019-08-27 15:22:38 +02:00
342a5cd829 Add gasLimit option when adding contributor 2019-08-27 13:16:43 +02:00
45064df737
Fix wrong logger function call 2019-08-27 12:57:36 +02:00
d9a9ad7793
Update deps
Fixes security vulns
2019-08-27 12:51:31 +02:00
d65e92e1fe
Merge branch 'master' into feature/github-signup 2019-08-27 12:39:15 +02:00
0fff3b1db0
Merge pull request #43 from 67P/feature/custom_dao_address
Add ENV var for custom DAO address
2019-08-27 12:38:45 +02:00
1f1053e289
Remove apm address from options
It's already the default in the contracts module.
2019-08-27 12:37:50 +02:00
84f20efff0
Add ENV var for custom DAO address
This makes it much easier to test hubot-kredits with local development
chains.
2019-08-27 12:19:01 +02:00
cd8343516f
3.3.1 2019-08-14 15:42:23 +02:00
Greg Karékinian
41c5903cf5
Merge pull request #42 from 67P/chore/mediawiki_small_threshold
Change threshold for small wiki contributions
2019-08-14 12:51:48 +02:00
9a4dc43aa5
Change threshold for small wiki contributions
Turns out 280 is a bit low. Let's try 500 instead.
2019-08-14 12:46:19 +02:00
082bf4848d Minor changes 2019-07-26 20:50:36 +02:00
41dd813eaf Replace custom CORS header solution with middleware 2019-07-26 20:48:20 +02:00
b35cbcc63a Finish implementation of GitHub signup oracle 2019-07-26 04:15:11 +02:00
137e9eb4ed Add initial implementation of the GitHub signup oracle 2019-07-11 15:44:58 +02:00
67fd9ff031
3.3.0 2019-05-08 16:45:55 +02:00
800950ebc5
Merge pull request #39 from 67P/feature/38-label_mappings
Map issue/PR labels to contribution kind
2019-05-08 16:42:05 +02:00
98ccae96f0
[gitea] Use labels for contribution kind 2019-05-08 16:16:40 +02:00
e6730802f6
Use proper syntax for sending HTTP status
Fixes deprecation warnings for the old syntax.
2019-05-08 16:14:41 +02:00
d2976b312e
Create utility modules for label processing 2019-05-08 16:14:41 +02:00
d2feee8b14
[github] Use labels for contribution kind 2019-05-08 16:14:24 +02:00
a5acf466a4
3.2.1 2019-04-30 10:41:50 +01:00
Greg Karékinian
f1bb5b391d
Merge pull request #35 from 67P/bugfix/33-gitea_issue_id
Fix wrong Gitea issue IDs/URLs
2019-04-30 10:26:21 +02:00
840fcf9d25
Add commented setting for custom local DAO address 2019-04-28 13:01:33 +01:00
c390fc9ce7
Fix Gitea issue ID/URL being wrong
Internal issue IDs are different from the public ones in Gitea. The one
used in URLs etc. is called "number" instead of "id" in hook payloads.

fixes #33
2019-04-28 13:01:09 +01:00
23033b3813
3.2.0 2019-04-24 14:25:51 +01:00
fb1b10d2a3
Merge pull request #31 from 67P/feature/24-gitea
Add Gitea integration
2019-04-24 14:25:06 +01:00
d3b97d9129
Update kredits-contracts
Includes the Gitea accounts in profiles
2019-04-24 14:24:21 +01:00
d2f487b379
Merge pull request #32 from 67P/chore/remove_network_id_config
Remove network ID setting
2019-04-24 13:06:39 +00:00
ca9732c333
Add Gitea info to README 2019-04-24 13:38:16 +01:00
c03da5659f
Remove network ID setting
Not needed anymore.
2019-04-24 13:34:00 +01:00
5a9edda1cd
Add Gitea integration
Support for Gitea organization hooks, based on GitHub hooks code.

closes #24
2019-04-24 13:27:18 +01:00
2a3077d733
3.1.2 2019-04-17 11:14:11 +01:00
1255d7ce19
Fix another wrong const 2019-04-17 11:13:49 +01:00
d9e09ce041
3.1.1 2019-04-17 09:38:19 +01:00
a0f982432e
Fix syntax error (variable re-assignment) 2019-04-17 09:38:00 +01:00
4550a911f0
3.1.0 2019-04-16 12:52:54 +01:00
d1580560b5
Update kredits-contracts 2019-04-16 12:51:49 +01:00
92c50ac69e
Merge pull request #30 from 67P/feature/29-date_time
Add contribution date
2019-04-13 16:07:46 +00:00
fca991c685
Add date to mediawiki edits
Use the day before, as we collect them at 7am every day.
2019-04-13 14:18:47 +01:00
626712356a
Add date and time for GitHub issues and PRs 2019-04-13 14:04:41 +01:00
65e34ee4e1
Update kredits-contracts 2019-04-13 12:44:10 +01:00
63057191d6
3.0.0 2019-04-08 15:02:01 +02:00
768c24da8e
Merge pull request #27 from 67P/feature/26-contributions
Create contributions instead of proposals
2019-04-08 15:01:08 +02:00
0fdd7d0f1c
Fix accidental commenting of cron schedule 2019-04-08 15:00:24 +02:00
1e0d637c26
"Fix" nonce issue for Mediawiki as well
See previous commit (dcbce66) for details.
2019-04-06 11:55:02 +02:00
dcbce66796
Hacky fix for nonce issues
This waits until a tx has likely been confirmed, before trying to send
the next one, because nonce management is broken in the ethers.js
wallet and it tries to replace a previous tx if two are sent quickly in
succession.

This will most likely break, when there are multiple independent
contrbution issuance calls (e.g. two PRs merged quickly in succession).
But at least it fixes issues/PRs with multiple assignees for most cases.
2019-04-06 11:41:19 +02:00
16b62a2545
Print actual tx error instead of guessing 2019-04-05 20:07:30 +02:00
f39bd41098
Create contributions instead of proposals 2019-04-05 19:52:48 +02:00
8cfbc222fc
Merge pull request #25 from 67P/update-kredits-contracts
Update kredits contracts
2019-04-05 19:20:17 +02:00
21aaf2f3bf
Use kredits-contracts from npm, update provider setup 2019-04-05 17:02:33 +02:00
7056772066
Fix function name for logger warning 2019-04-05 16:03:48 +02:00
d854b4e0fb Update npm packages 2019-03-31 17:17:16 +02:00
321fe95a27 Operator is now Proposal 2019-03-31 17:16:55 +02:00
b38e6faf8a Prepare for kredits-contracts update
Also updates ethers.js to v4.x
2019-03-31 17:09:58 +02:00
bfaf26cc9a
Use robot logger 2019-03-13 12:21:48 +07:00
3ac2774c4f 2.1.3 2018-09-09 15:36:52 +08:00
83990a36dc
Merge pull request #23 from 67P/bugfix/mediawiki_cron_syntax
Fix mediawiki polling cron
2018-09-09 15:36:25 +08:00
c427d7c313 Fix mediawiki polling cron
Was doing every minute after 7am, instead of every day once at 7am.
2018-09-09 15:35:09 +08:00
8b1977b53e 2.1.2 2018-06-14 15:03:30 +02:00
189f7fe4ee Fix amount calculation
Was using the wrong object as argument.
2018-06-14 15:02:20 +02:00
a95bf0141e 2.1.1 2018-05-07 14:04:15 +02:00
b4f6fb7ef7 Add missing require 2018-05-07 14:03:50 +02:00
98f4516217 2.1.0 2018-05-06 21:15:04 +02:00
149bc0c052 Use new wrapper function for finding contributor
Fixes a bug with the find algo as well.
2018-05-06 21:14:04 +02:00
c190490d9b Add context to contribution description
It didn't actually say where the pages were edited, i.e. the wiki.
2018-05-06 21:12:57 +02:00
eb767a90f2 Deduplicate title of pages edited/created 2018-05-06 21:12:37 +02:00
156ea44f0d Use configured wiki URL for contribution details 2018-05-06 21:12:06 +02:00
ec7f4a4d33 2.0.1 2018-05-03 13:32:15 +02:00
5f9e237f36 Add missing keywords to package config 2018-05-03 13:31:13 +02:00
38db976c29 2.0.0 2018-05-03 13:27:11 +02:00
36e73beb3d Update README 2018-05-03 13:24:53 +02:00
8d067d221f Update kredits-contracts to latest release 2018-05-03 13:21:00 +02:00
35222456a4
Merge pull request #19 from 67P/docs/9-readme
Add README/documentation
2018-04-22 14:36:54 +00:00
7636e08e8f
Merge pull request #20 from 67P/dev/cleanup
Remove obsolete lib files
2018-04-22 14:36:05 +00:00
b4229c7879 Document wallet creation, rename npm script 2018-04-22 13:47:32 +02:00
201c982466 Remove obsolete lib files
We're using the npm module now
2018-04-22 13:42:59 +02:00
bba5407c7d Fix typo, link kredits wiki page 2018-04-22 13:39:29 +02:00
417eb42905 Add README/documentation 2018-04-22 13:20:08 +02:00
b852bd24af
Merge pull request #16 from 67P/feature/6-mediawiki_integration
MediaWiki integration & integration architecture improvements
2018-04-21 09:31:29 +00:00
581b15da69 Cron job for checking mediawiki changes 2018-04-19 19:21:05 +02:00
c97cc82817 Only fetch wiki changes since last processing 2018-04-19 19:04:20 +02:00
d4a3a9c9df Add hardcoded amounts for wiki edits 2018-04-19 18:00:19 +02:00
6c3070b43b Increase kredits amounts for GitHub labels 2018-04-19 17:54:59 +02:00
9d8b2c08dc [WIP] Add proposals for wiki changes 2018-04-19 17:11:02 +02:00
2780c87aaa Last GitHub fix 2018-04-19 17:10:51 +02:00
7904aa8c84 Remove robot stub from mediawiki 2018-04-19 14:39:02 +02:00
3d810287c0 Implement GitHub repo blacklist
closes #17
2018-04-19 14:38:09 +02:00
22b480ae92 Fix typos 2018-04-19 14:37:55 +02:00
48a42d4f2c Improve logging 2018-04-19 14:37:21 +02:00
aab5b58bab Add missing module imports 2018-04-19 14:35:26 +02:00
c0c2f97dae Add MediaWiki integration basics 2018-04-19 12:32:03 +02:00
74429feb9d Use contracts from master 2018-04-19 12:31:36 +02:00
ed5d127b4c Add missing variables to GitHub integration 2018-04-19 12:18:12 +02:00
e8da47db70 Use relative path for integration module 2018-04-19 12:10:44 +02:00
462efcefbd Use robot.logger 2018-04-19 12:10:34 +02:00
5259b56e53 Split out integrations, add code section comments 2018-04-19 11:50:59 +02:00
33cefac88e Add node-cron 2018-04-19 11:39:33 +02:00
5a2ebb323d Basic MediaWiki changes integration 2018-04-19 11:38:01 +02:00
eecfa7e8f2 Improve variable assignment 2018-04-19 11:35:09 +02:00
5d76b2dead Revert "minor refactorings"
This reverts commit 8c481179b4387eb74b9584adf5cc189a5be5fcf3.
2018-04-19 11:34:47 +02:00
22cb49df2d Add missing network id documentation 2018-04-19 01:08:03 +02:00
8c481179b4 minor refactorings 2018-04-19 01:01:33 +02:00
2be3d4e8e3 Improve room messages a bit
And don't notify everyone for every contribution.
2018-04-19 00:44:12 +02:00
f4b484ed5a Improve logging
Add prefix, some formatting
2018-04-19 00:42:50 +02:00
e4fb97c0c9 Better message for new proposal notifications 2018-04-19 00:21:48 +02:00
3fe6dfb2ce properly handle github webhooks 2018-04-19 00:13:42 +02:00
9dc1ece94f Working kredits-contract integration 2018-04-18 22:54:41 +02:00
e99addf37e Use kredits-contracts from truffle-kredits 2018-04-18 20:50:43 +02:00
050e8ce79f WIP update to use new kredits library
Using the new kredits library to load contract data and create
proposals.
currently the kredits lib is only copied - which needs to be removed
asap.
2018-04-18 16:27:22 +02:00
821543056b Use ethers.js to create a new wallet
ethers.js is smaller and nicer than web3
2018-04-18 16:25:46 +02:00
18 changed files with 8006 additions and 425 deletions

2
.env.example Normal file
View File

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

4
.github/release-drafter.yml vendored Normal file
View File

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

1
.gitignore vendored
View File

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

148
README.md Normal file
View File

@ -0,0 +1,148 @@
[![npm](https://img.shields.io/npm/v/@kredits/hubot-kredits.svg)](https://www.npmjs.com/package/@kredits/hubot-kredits)
# Hubot Kredits
This repository provides scripts for integrating [Kosmos
Kredits](https://wiki.kosmos.org/Kredits) in [Hubot](http://hubot.github.com/)
chatbots. The bot will watch for project-related things happening on the
Internet and automatically create ERC721 tokens for issuing kredits for project
contributions.
## Setup
### Wallet
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
As usual in Hubot, you can add all config as environment variables.
| Key | Description |
| --- | --- |
| `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 wallet JSON file (default: `./wallet.json`) |
| `KREDITS_WALLET_PASSWORD` | Wallet password |
| `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_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
### GitHub
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,
medium and large contributions. If there are multiple people assigned, it will
issue contribution tokens 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
Point a GitHub organization webhook to the following URL:
https://your-hubot.example.com/incoming/kredits/github/{webhook_token}
#### Config
| 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_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
The MediaWiki integration will periodically check for wiki page creations and
edits. It will create kredits contribution tokens based on amount of text added.
#### Setup
No setup needed, except for configuring the wiki URL. The bot will poll your
wiki's API on its own.
#### Config
| Key | Description |
| --- | --- |
| `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)

481
index.js
View File

@ -1,431 +1,126 @@
// Description:
// Kosmos Kredits chat integration
//
// Configuration:
// KREDITS_WEBHOOK_TOKEN: A string for building your secret webhook URL
// KREDITS_ROOM: Kredit proposals are posted to this chatroom
// KREDITS_WALLET_PATH: Path to a etherum wallet JSON file
// KREDITS_WALLET_PASSWORD: Wallet password
// KREDITS_CONTRACT_ADDRESS: Address of Kredits contract
// KREDITS_PROVIDER_URL: Ethereum JSON-RPC URL (default 'http://localhost:8545')
// IPFS_API_HOST: Host/domain (default 'localhost')
// IPFS_API_PORT: Port number (default '5001')
// IPFS_API_PROTOCOL: Protocol, e.g. 'http' or 'https' (default 'http')
//
const fs = require('fs');
const util = require('util');
const fetch = require('node-fetch');
const kreditsContracts = require('kredits-contracts');
const ProviderEngine = require('web3-provider-engine');
const Wallet = require('ethereumjs-wallet');
const WalletSubprovider = require('ethereumjs-wallet/provider-engine');
const Web3Subprovider = require('web3-provider-engine/subproviders/web3.js');
const Web3 = require('web3');
const ipfsAPI = require('ipfs-api');
const schemas = require('kosmos-schemas');
const tv4 = require('tv4');
const ethers = require('ethers');
const NonceManager = require('@ethersproject/experimental').NonceManager;
const Kredits = require('@kredits/contracts');
(function() {
"use strict";
const walletPath = process.env.KREDITS_WALLET_PATH || './wallet.json';
const walletJson = fs.readFileSync(walletPath);
const providerUrl = process.env.KREDITS_PROVIDER_URL || 'http://localhost:7545';
//
// Instantiate ethereum client and wallet
//
let engine = new ProviderEngine();
let walletPath = process.env.KREDITS_WALLET_PATH || './wallet.json';
let walletJson = fs.readFileSync(walletPath);
let wallet = Wallet.fromV3(JSON.parse(walletJson), process.env.KREDITS_WALLET_PASSWORD);
let providerUrl = process.env.KREDITS_PROVIDER_URL || 'http://localhost:8545';
let hubotWalletAddress = '0x' + wallet.getAddress().toString('hex');
engine.addProvider(new WalletSubprovider(wallet, {}));
engine.addProvider(new Web3Subprovider(new Web3.providers.HttpProvider(providerUrl)));
// TODO only start engine if providerURL is accessible
engine.start();
let web3 = new Web3(engine);
web3.eth.defaultAccount = hubotWalletAddress;
//
// Instantiate contracts
//
let contractConfig = {};
if (process.env.KREDITS_CONTRACT_ADDRESS) {
contractConfig = { Kredits: { address: process.env.KREDITS_CONTRACT_ADDRESS }};
}
let contracts = kreditsContracts(web3, contractConfig);
let kredits = contracts['Kredits'];
//
// Instantiate IPFS API client
//
let ipfsConfig = {};
if (process.env.IPFS_API_HOST) {
ipfsConfig = {
host: process.env.IPFS_API_HOST,
port: process.env.IPFS_API_PORT,
protocol: process.env.IPFS_API_PROTOCOL
const ipfsConfig = {
host: process.env.IPFS_API_HOST || 'localhost',
port: process.env.IPFS_API_PORT || '5001',
protocol: process.env.IPFS_API_PROTOCOL || 'http'
};
}
let ipfs = ipfsAPI(ipfsConfig);
module.exports = function(robot) {
robot.logger.info('[hubot-kredits] Wallet address: ' + hubotWalletAddress);
getBalance().then(balance => {
if (balance <= 0) {
messageRoom(`Yo gang, I\'m broke! Please drop me some ETH to ${hubotWalletAddress}. kthxbai.`);
}
});
function getBalance() {
return new Promise((resolve, reject) => {
web3.eth.getBalance(hubotWalletAddress, function (err, balance) {
if (err) {
robot.logger.error('[hubot-kredits] Error checking balance');
reject(err);
return;
}
resolve(balance);
});
});
}
function getValueFromContract(contractMethod, ...args) {
return new Promise((resolve, reject) => {
kredits[contractMethod](...args, (err, data) => {
if (err) { reject(err); }
resolve(data);
});
});
}
function loadProfileFromIPFS(contributor) {
let promise = new Promise((resolve, reject) => {
return ipfs.cat(contributor.ipfsHash, { buffer: true }).then(res => {
let content = res.toString();
let profile = JSON.parse(content);
contributor.name = profile.name;
contributor.kind = profile.kind;
let accounts = profile.accounts;
let github = accounts.find(a => a.site === 'github.com');
let wiki = accounts.find(a => a.site === 'wiki.kosmos.org');
if (github) {
contributor.github_username = github.username;
contributor.github_uid = github.uid;
}
if (wiki) {
contributor.wiki_username = wiki.username;
}
resolve(contributor);
}).catch((err) => {
console.log(err);
reject(err);
});
});
return promise;
}
function getContributorData(i) {
let promise = new Promise((resolve, reject) => {
getValueFromContract('contributorAddresses', i).then(address => {
// robot.logger.debug('address', address);
getValueFromContract('contributors', address).then(person => {
// robot.logger.debug('person', person);
let c = {
address: address,
name: person[1],
id: person[0],
ipfsHash: person[2]
};
if (c.ipfsHash) {
// robot.logger.debug('[kredits] loading contributor profile loaded for', c.name, c.ipfsHash, '...');
loadProfileFromIPFS(c).then(contributor => {
// robot.logger.debug('[kredits] contributor profile loaded for', c.name);
resolve(contributor);
}).catch(() => console.log('[kredits] error fetching contributor info from IPFS for '+c.name));
} else {
resolve(c);
}
});
}).catch(err => reject(err));
});
return promise;
}
function getContributors() {
return getValueFromContract('contributorsCount').then(contributorsCount => {
let contributors = [];
for(var i = 0; i < contributorsCount.toNumber(); i++) {
contributors.push(getContributorData(i));
}
return Promise.all(contributors);
});
}
function getContributorByGithubUser(username) {
let promise = new Promise((resolve, reject) => {
getContributors().then(contributors => {
let contrib = contributors.find(c => {
return c.github_username === username;
});
if (contrib) {
resolve(contrib);
} else {
reject();
}
});
});
return promise;
}
function getContributorByAddress(address) {
let promise = new Promise((resolve, reject) => {
getContributors().then(contributors => {
let contrib = contributors.find(c => {
return c.address === address;
});
if (contrib) {
resolve(contrib);
} else {
reject();
}
});
});
return promise;
}
module.exports = async function(robot) {
function messageRoom(message) {
robot.messageRoom(process.env.KREDITS_ROOM, message);
}
function amountFromIssueLabels(issue) {
let kreditsLabel = issue.labels.map(l => l.name)
.filter(n => n.match(/^kredits/))[0];
// No label, no kredits
if (typeof kreditsLabel === 'undefined') { return 0; }
//
// Ethereum wallet setup
//
// TODO move to config maybe?
let amount;
switch(kreditsLabel) {
case 'kredits-1':
amount = 50;
break;
case 'kredits-2':
amount = 150;
break;
case 'kredits-3':
amount = 500;
break;
let wallet;
try {
wallet = await ethers.Wallet.fromEncryptedJson(walletJson, process.env.KREDITS_WALLET_PASSWORD);
} catch(error) {
robot.logger.warning('[hubot-kredits] Could not load wallet:', error);
process.exit(1);
}
return amount;
//
// Ethereum provider/node setup
//
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 };
let kredits;
try {
kredits = await new Kredits(signer.provider, signer, opts).init();
} catch(error) {
robot.logger.warning('[hubot-kredits] Could not set up kredits:', error);
process.exit(1);
}
const Contributor = kredits.Contributor;
const Contribution = kredits.Contribution;
// TODO const Reimbursement = kredits.Reimbursement;
function createContributionDocument(contributor, url, description, details) {
let contribution = {
"@context": "https://schema.kosmos.org",
"@type": "Contribution",
contributor: {
ipfs: contributor.ipfsHash
},
kind: 'dev',
url: url,
description: description,
details: details
};
robot.logger.info('[hubot-kredits] Wallet address: ' + wallet.address);
if (! tv4.validate(contribution, schemas["contribution"])) {
robot.logger.error('[kredits] invalid contribution data: ', util.inspect(contribution));
return Promise.reject('invalid contribution data');
//
// Check robot's wallet balance and alert when it's broke
//
ethProvider.getBalance(wallet.address).then(balance => {
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 send some RBTC to ${wallet.address}. kthxbai.`);
}
// robot.logger.debug('[kredits] creating IPFS document for contribution:', contribution.description);
return ipfs.add(new ipfs.Buffer(JSON.stringify(contribution)))
.then(res => {
// robot.logger.debug('[kredits] created IPFS document', res[0].hash);
return res[0].hash;
}).catch(err => robot.logger.error('[kredits] couldn\'t create IPFS document', err));
}
function createProposal(recipient, amount, url, description, details) {
robot.logger.debug(`[kredits] Creating proposal to issue ${amount}₭S to ${recipient} for ${url}...`);
return new Promise((resolve, reject) => {
// Get contributor details for GitHub user
getContributorByGithubUser(recipient).then(c => {
// Create document containing contribution data on IPFS
createContributionDocument(c, url, description, details).then(ipfsHash => {
// Create proposal on ethereum blockchain
kredits.addProposal(c.address, amount, url, ipfsHash, (e, d) => {
if (e) { reject(e); return; }
robot.logger.debug('[kredits] proposal created:', util.inspect(d));
resolve();
});
});
}, () => {
messageRoom(`I wanted to propose giving kredits to ${recipient} for ${url}, but I can't find their contact data. Please add them as a contributor: https://kredits.kosmos.org`);
resolve();
});
});
}
function handleGitHubIssueClosed(data) {
return new Promise((resolve/*, reject*/) => {
// fs.writeFileSync('tmp/github-issue.json', JSON.stringify(data, null, 4));
let recipients;
let issue = data.issue;
let assignees = issue.assignees.map(a => a.login);
let web_url = issue.html_url;
let amount = amountFromIssueLabels(issue);
if (amount === 0) { resolve(); return; }
if (assignees.length > 0) {
recipients = assignees;
} else {
recipients = [issue.user.login];
}
let repoName = issue.repository_url.match(/.*\/(.+\/.+)$/)[1];
let description = `${repoName}: ${issue.title}`;
recipients.forEach(recipient => {
createProposal(recipient, amount, web_url, description, issue)
.catch(err => robot.logger.error(err));
});
resolve();
});
}
//
// Robot chat commands/interaction
//
function handleGitHubPullRequestClosed(data) {
return new Promise((resolve, reject) => {
// fs.writeFileSync('tmp/github-pr.json', JSON.stringify(data, null, 4));
let recipients;
let pull_request = data.pull_request;
let assignees = pull_request.assignees.map(a => a.login);
let web_url = pull_request._links.html.href;
let pr_issue_url = pull_request.issue_url;
if (assignees.length > 0) {
recipients = assignees;
} else {
recipients = [pull_request.user.login];
}
fetch(pr_issue_url)
.then(response => {
if (response.status >= 400) {
reject('Bad response from fetching PR issue');
}
return response.json();
})
.then(issue => {
// fs.writeFileSync('tmp/github-pr-issue.json', JSON.stringify(data, null, 4));
let amount = amountFromIssueLabels(issue);
if (amount === 0) { resolve(); return; }
let repoName = pull_request.base.repo.full_name;
let description = `${repoName}: ${pull_request.title}`;
recipients.forEach(recipient => {
createProposal(recipient, amount, web_url, description, pull_request)
.catch(err => robot.logger.error(err));
});
resolve();
});
});
}
robot.respond(/(got ETH)|(got gas)\?/i, res => {
getBalance().then(balance => {
if (balance <= 0) {
res.send(`HALP, I\'m totally broke! Not a single wei in my pocket.`);
}
else if (balance >= 1e+17) {
res.send(`my wallet contains ${web3.fromWei(balance, 'ether')} ETH`);
}
else {
res.send(`I\'m almost broke! Only have ${web3.fromWei(balance, 'ether')} ETH left in my pocket. :(`);
}
robot.respond(/got RBTC\??/i, res => {
ethProvider.getBalance(wallet.address).then((balance) => {
res.send(`My wallet contains ${ethers.utils.formatEther(balance)} RBTC`);
});
});
robot.router.post('/incoming/kredits/github/'+process.env.KREDITS_WEBHOOK_TOKEN, (req, res) => {
let evt = req.header('X-GitHub-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 GitHub hook. Event: ${evt}, action: ${data.action}`);
if (evt === 'pull_request' && data.action === 'closed') {
handleGitHubPullRequestClosed(data).then(() => res.send(200));
}
else if (evt === 'issues' && data.action === 'closed') {
handleGitHubIssueClosed(data).then(() => res.send(200));
} else {
res.send(200);
}
});
//
// Smart contract events
//
function watchContractEvents() {
web3.eth.getBlockNumber((err, blockNumber) => {
if (err) {
robot.logger.error('[kredits] couldn\t get current block number');
return false;
}
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(`[kredits] watching events from block ${nextBlock} onward`);
robot.logger.debug(`[hubot-kredits] Watching events from block ${nextBlock} onward`);
ethProvider.resetEventsBlock(nextBlock);
kredits.allEvents({fromBlock: nextBlock, toBlock: 'latest'}, (error, data) => {
robot.logger.debug('[kredits] received contract event', data.event);
if (data.blockNumber < nextBlock) {
// I don't know why, but the filter doesn't work as intended
robot.logger.debug('[kredits] dismissing old event from block', data.blockNumber);
return false;
}
switch (data.event) {
case 'ProposalCreated':
handleProposalCreated(data);
break;
// case 'ProposalExecuted':
// handleProposalExecuted(data);
// break;
// case 'ProposalVoted':
// handleProposalVoted(data);
// break;
// case 'Transfer':
// handleTransfer(data);
// break;
}
});
// TODO handle all known events (that make sense here)
// Contribution.on('ContributorAdded', handleContributorAdded);
Contribution.on('ContributionAdded', handleContributionAdded);
});
}
function handleProposalCreated(data) {
getContributorByAddress(data.args.recipient).then((contributor) => {
messageRoom(`Let's give ${contributor.name} some kredits for ${data.args.url}: https://kredits.kosmos.org`);
function handleContributionAdded(contributionId, contributorId, amount) {
Contributor.getById(contributorId).then(_ => {
Contribution.getById(contributionId).then(contribution => {
robot.logger.debug(`[hubot-kredits] Contribution #${contribution.id} added (${amount} kredits for "${contribution.description}")`);
});
});
}
watchContractEvents();
//
// Integrations
//
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') {
require('./integrations/mediawiki')(robot, kredits);
}
};
}());

165
integrations/gitea.js Normal file
View File

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

276
integrations/github.js Normal file
View File

@ -0,0 +1,276 @@
const util = require('util');
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) {
function messageRoom(message) {
robot.messageRoom(process.env.KREDITS_ROOM, message);
}
robot.logger.debug('[hubot-kredits] Loading GitHub integration...');
let repoBlackList = [];
if (process.env.KREDITS_GITHUB_REPO_BLACKLIST) {
repoBlackList = process.env.KREDITS_GITHUB_REPO_BLACKLIST.split(',');
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 Contribution = kredits.Contribution;
function getContributorByGithubUser(username) {
return Contributor.all().then(contributors => {
const contrib = contributors.find(c => {
return c.github_username === username;
});
if (!contrib) {
throw new Error(`No contributor found for ${username}`);
} else {
return contrib;
}
});
}
function createContribution(githubUser, date, time, amount, kind, description, url, details) {
return getContributorByGithubUser(githubUser).then(contributor => {
robot.logger.info(`[hubot-kredits] Creating contribution token for ${amount}₭S to ${githubUser} 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 ${githubUser} for ${url}, but I encountered an error when submitting the tx:`);
messageRoom(error.message);
});
});
}
async function handleGitHubIssueClosed(data) {
let recipients;
const issue = data.issue;
const assignees = issue.assignees.map(a => a.login);
const web_url = issue.html_url;
[date, time] = issue.closed_at.split('T');
const labels = issue.labels.map(l => l.name);
const amount = amountFromLabels(labels);
const kind = kindFromLabels(labels);
const repoName = issue.repository_url.match(/.*\/(.+\/.+)$/)[1];
const description = `${repoName}: ${issue.title}`;
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();
}
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);
await sleep(60000);
}
catch (err) { robot.logger.error(err); }
}
return Promise.resolve();
}
function handleGitHubPullRequestClosed(data) {
let recipients;
const pull_request = data.pull_request;
const assignees = pull_request.assignees.map(a => a.login);
const web_url = pull_request._links.html.href;
const pr_issue_url = pull_request.issue_url;
[date, time] = pull_request.merged_at.split('T');
if (assignees.length > 0) {
recipients = assignees;
} else {
recipients = [pull_request.user.login];
}
return fetch(pr_issue_url)
.then(response => {
if (response.status >= 400) {
throw new Error('Bad response from fetching PR issue');
}
return response.json();
})
.then(async (issue) => {
const labels = issue.labels.map(l => l.name);
const amount = amountFromLabels(labels);
const kind = kindFromLabels(labels);
const repoName = pull_request.base.repo.full_name;
const description = `${repoName}: ${pull_request.title}`;
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();
}
for (const recipient of recipients) {
try {
await createContribution(recipient, date, time, amount, kind, description, web_url, pull_request);
await sleep(60000);
}
catch (err) { robot.logger.error(err); }
}
return Promise.resolve();
});
}
robot.router.post('/incoming/kredits/github/'+process.env.KREDITS_WEBHOOK_TOKEN, (req, res) => {
const evt = req.header('X-GitHub-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 GitHub hook. Event: ${evt}, action: ${data.action}`);
if (evt === 'pull_request' && data.action === 'closed' && data.pull_request.merged) {
handleGitHubPullRequestClosed(data);
res.sendStatus(200);
}
else if (evt === 'issues' && data.action === 'closed') {
handleGitHubIssueClosed(data);
res.sendStatus(200);
} else {
res.sendStatus(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');
}
};

181
integrations/mediawiki.js Normal file
View File

@ -0,0 +1,181 @@
const url = require('url');
const util = require('util');
const fetch = require('node-fetch');
const groupArray = require('group-array');
const cron = require('node-cron');
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 MediaWiki integration...')
const Contributor = kredits.Contributor;
const Contribution = kredits.Contribution;
const wikiURL = process.env.KREDITS_MEDIAWIKI_URL;
const apiURL = wikiURL + 'api.php';
function getContributorByWikiUser(username) {
let account = {
site: url.parse(process.env.KREDITS_MEDIAWIKI_URL).hostname,
username: username
}
return Contributor.findByAccount(account).then(contributor => {
robot.logger.debug('CONTRIBUTOR: ', contributor)
if (contributor) { return contributor; } else { throw new Error(); }
});
}
function createContribution(username, date, amount, description, url, details={}) {
return getContributorByWikiUser(username).then(contributor => {
robot.logger.debug(`[hubot-kredits] Creating contribution token for ${amount}₭S to ${contributor.name} for ${url}...`);
let contribution = {
contributorId: contributor.id,
contributorIpfsHash: contributor.ipfsHash,
date,
amount: amount,
url,
description,
details,
kind: 'docs'
};
return Contribution.add(contribution).catch(error => {
robot.logger.error(`[hubot-kredits] Adding contribution failed:`, error);
});
}).catch(() => {
robot.logger.info(`[hubot-kredits] No contributor found for ${username}`);
messageRoom(`I wanted to propose giving kredits to wiki user ${username}, but I cannot find their info. Please add them as a contributor: https://kredits.kosmos.org`);
});
}
function fetchChanges () {
const params = [
'action=query',
'format=json',
'list=recentchanges',
'rctype=edit|new',
'rcshow=!minor|!bot|!anon|!redirect',
'rclimit=max',
'rcprop=ids|title|timestamp|user|sizes|comment|flags'
];
let endTime = robot.brain.get('kredits:mediawiki:last_processed_at');
if (endTime) {
robot.logger.debug(`[hubot-kredits] Fetching wiki edits since ${endTime}`);
params.push(`rcend=${endTime}`);
}
const url = `${apiURL}?${params.join('&')}`;
return fetch(url).then(res => {
if (res.status === 200) {
return res.json();
} else {
robot.logger.info(`Fetching ${url} returned HTTP status ${res.status}:`);
robot.logger.info(res.body);
throw Error('Unexpected response from '+url);
}
}).then(res => {
return res.query.recentchanges;
}).catch(res => {
robot.logger.error(`[hubot-kredits] Failed to fetch ${url} (likely due to a network issue)`);
});
}
function groupChangesByUser (changes) {
return Promise.resolve(groupArray(changes, 'user'));
}
function analyzeUserChanges (user, changes) {
// robot.logger.debug(`Analyzing ${changes.length} edits from ${user} ...`);
const results = {};
results.pagesCreated = changes.filter(c => c.type === 'new');
results.pagesChanged = changes.filter(c => c.type === 'edit');
results.charsAdded = changes
.map(c => { return (c.oldlen < c.newlen) ? (c.newlen - c.oldlen) : 0; })
.reduce((a, b) => a + b);
// robot.logger.debug(`Created ${results.pagesCreated.length} pages`);
// robot.logger.debug(`Edited ${results.pagesChanged.length} pages`);
// robot.logger.debug(`Added ${results.charsAdded} lines of text\n`);
return results;
}
async function createContributions (changes) {
for (const user of Object.keys(changes)) {
await createContributionForUserChanges(user, changes[user]);
await sleep(60000);
}
}
function pageTitlesFromChanges(changes) {
return [...new Set(changes.map(c => `"${c.title}"`))].join(', ');
}
// Currently not used
function calculateAmountForChanges(details) {
let amount;
if (details.charsAdded < 500) {
amount = 500;
} else if (details.charsAdded < 2000) {
amount = 1500;
} else {
amount = 5000;
}
return amount;
}
function createContributionForUserChanges (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 amount = 500;
let desc = `Added ${details.charsAdded} characters of text.`;
if (details.pagesChanged.length > 0) {
desc = `Edited ${pageTitlesFromChanges(details.pagesChanged)}. ${desc}`;
}
if (details.pagesCreated.length > 0) {
desc = `Created ${pageTitlesFromChanges(details.pagesCreated)}. ${desc}`;
}
desc = `Wiki contributions: ${desc}`;
let url;
if (changes.length > 1) {
url = `${wikiURL}Special:Contributions/${user}?hideMinor=1`;
} else {
rc = changes[0];
url = `${wikiURL}index.php?title=${rc.title}&diff=${rc.revid}&oldid=${rc.old_revid}`;
}
return createContribution(user, date, amount, desc, url, details);
}
function updateTimestampForNextFetch () {
robot.logger.debug(`[hubot-kredits] Set timestamp for wiki changes fetch`);
robot.brain.set('kredits:mediawiki:last_processed_at', new Date().toISOString());
}
function processWikiChangesSinceLastRun () {
fetchChanges()
.then(res => groupChangesByUser(res))
.then(res => createContributions(res))
.then(() => updateTimestampForNextFetch());
}
cron.schedule('0 7 * * *', processWikiChangesSinceLastRun);
};

View File

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

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

102
integrations/zoom.js Normal file
View File

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

6495
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,36 +1,47 @@
{
"name": "hubot-kredits",
"version": "1.7.0",
"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": {
"kredits-wallet-create": "scripts/create-wallet.js"
"create-wallet": "scripts/create-wallet.js",
"review-kredits": "scripts/review-kredits.js"
},
"dependencies": {
"ethereumjs-wallet": "mvayngrib/ethereumjs-wallet",
"ipfs-api": "^14.0.3",
"kosmos-schemas": "^1.1.2",
"kredits-contracts": "2.0.0",
"node-fetch": "^1.6.3",
"prompt": "^1.0.0",
"tv4": "^1.3.0",
"web3": "^0.18.4",
"web3-provider-engine": "^12.0.3"
"@ethersproject/experimental": "5.7.0",
"@kredits/contracts": "^7.3.0",
"axios": "^1.7.9",
"cors": "^2.8.5",
"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": "^2.2.1",
"node-cron": "^2.0.3",
"node-fetch": "^2.6.12",
"prompt": "^1.3.0",
"yargs": "^17.7.2"
},
"repository": {
"type": "git",
"url": "git+https://github.com/67P/hubot-kredits.git"
},
"keywords": [
"kosmos"
"kosmos",
"kredits",
"hubot-scripts"
],
"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

@ -1,7 +1,7 @@
#!/usr/bin/env node
const fs = require('fs');
const Wallet = require('ethereumjs-wallet');
const ethers = require('ethers');
const userPrompt = require('prompt');
let schema = {
@ -24,10 +24,10 @@ userPrompt.start();
userPrompt.get(schema, (err, result) => {
if (err) { throw(err); }
let wallet = Wallet.generate();
let content = JSON.stringify(wallet.toV3(result.password));
fs.writeFileSync(result.path, content);
let wallet = ethers.Wallet.createRandom();
wallet.encrypt(result.password).then((walletJSON) => {
fs.writeFileSync(result.path, walletJSON);
console.log(`\nWrote encrypted wallet config to ${result.path}`);
});
});

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,
});
}
}
}
);
});