diff --git a/.cspell.json b/.cspell.json
new file mode 100644
index 000000000..9ed86f948
--- /dev/null
+++ b/.cspell.json
@@ -0,0 +1,57 @@
+{
+ "useGitignore": true,
+ "version": "0.2",
+ "language": "en",
+ "words": [
+ "autodetection",
+ "autopay",
+ "AUTOPAY",
+ "bucketid",
+ "bucketname",
+ "demilestoned",
+ "devpool",
+ "ensname",
+ "fkey",
+ "gelato",
+ "Gelato",
+ "gollum",
+ "keccak",
+ "libsodium",
+ "logdna",
+ "LOGDNA",
+ "mdast",
+ "Mdast",
+ "mergeable",
+ "micromark",
+ "milestoned",
+ "Numberish",
+ "orgname",
+ "pavlovcik",
+ "permisson",
+ "prereleased",
+ "probot",
+ "Probot",
+ "ratelimit",
+ "rebaseable",
+ "rerequested",
+ "scalarmult",
+ "signoff",
+ "sortcolumn",
+ "sortorder",
+ "supabase",
+ "Supabase",
+ "SUPABASE",
+ "svgs",
+ "timelabel",
+ "TURL",
+ "typebox",
+ "Ubiqui",
+ "ubiquibot",
+ "unarchived",
+ "Unassigning",
+ "Upserting",
+ "URLSAFE",
+ "vitalik",
+ "WXDAI"
+ ]
+}
diff --git a/.env.example b/.env.example
index f96ce5b63..b6102ad3d 100644
--- a/.env.example
+++ b/.env.example
@@ -10,6 +10,20 @@ SUPABASE_KEY=
AUTO_PAY_MODE=
ANALYTICS_MODE=
+
# Use `trace` to get verbose logging or `info` to show less
LOG_LEVEL=debug
LOGDNA_INGESTION_KEY=
+OPENAI_API_HOST=https://api.openai.com
+OPENAI_API_KEY=
+CHATGPT_USER_PROMPT_FOR_IMPORTANT_WORDS="I need your help to find important words (e.g. unique adjectives) from github issue below and I want to parse them easily so please separate them using #(No other contexts needed). Please separate the words by # so I can parse them easily. Please answer simply as I only need the important words. Here is the issue content.\n"
+CHATGPT_USER_PROMPT_FOR_MEASURE_SIMILARITY='I have two github issues and I need to measure the possibility of the 2 issues are the same content (No other contents needed and give me only the number in %).\n Give me in number format and add % after the number.\nDo not tell other things since I only need the number (e.g. 85%). Here are two issues:\n 1. "%first%"\n2. "%second%"'
+SIMILARITY_THRESHOLD=80
+MEASURE_SIMILARITY_AI_TEMPERATURE=0
+IMPORTANT_WORDS_AI_TEMPERATURE=0
+
+# Telegram Log Notification Envs
+LOG_WEBHOOK_BOT_URL= # URL of cloudflare worker without trailing /
+LOG_WEBHOOK_SECRET= # Random Secret, Shared between the telegram bot and the sender
+LOG_WEBHOOK_GROUP_ID= # Group Id, ex: -100124234325
+LOG_WEBHOOK_TOPIC_ID= # Topic Id (Optional), Only provide if group is a topic and you're not using General
\ No newline at end of file
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 82c537bb4..4a5a1e837 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1 +1 @@
-* @0xcodercrane
\ No newline at end of file
+* @0xcodercrane
diff --git a/.github/ISSUE_TEMPLATE/bounty-template.yml b/.github/ISSUE_TEMPLATE/bounty-template.yml
index eb78b826f..7e81ebf0c 100644
--- a/.github/ISSUE_TEMPLATE/bounty-template.yml
+++ b/.github/ISSUE_TEMPLATE/bounty-template.yml
@@ -1,18 +1,20 @@
name: "Bounty Proposal"
description: Have a suggestion for how to improve UbiquiBot? Let us know!
-title: "Bounty Proposal:"
+title: "Bounty Proposal: "
body:
- type: markdown
attributes:
value: |
## Feature Request Form
- Thank you for taking the time to file a feature request! Please let us know what you're trying to do, and how UbiquiBot can help.
+ Thank you for taking the time to file a feature request.
+ If you register your wallet address, you will be eligible for compensation if this is accepted!
+ Please let us know how we can improve the bot.
- type: textarea
attributes:
label: Describe the background or context
- description: Please let us know what inspired you to write this proposal. Backlinking to specific comments is usually sufficient context.
+ description: Please let us know what inspired you to write this proposal. Backlinking to specific comments on GitHub, and leaving a remark about how the bot should have interacted with it is usually sufficient context.
validations:
required: false
@@ -22,3 +24,10 @@ body:
description: A clear description of what you want to happen. Add any considered drawbacks.
validations:
required: true
+
+ - type: textarea
+ attributes:
+ label: Remarks
+ description: Any closing remarks?
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 86f3b5e6b..2403ad576 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -1,5 +1,5 @@
blank_issues_enabled: true
contact_links:
- - name: Telegram Group Chat
- url: https://t.me/UbiquityDAO/29891
- about: "Join us on Telegram!"
+ - name: UbiquiBot Development Group Chat
+ url: https://t.me/UbiquityDAO/31132
+ about: "Live chat with us on Telegram!"
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 9d7bbd50f..b1b688448 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,7 +1,11 @@
+
+
Resolves #
-
+Quality Assurance:
diff --git a/.github/ubiquibot-config.yml b/.github/ubiquibot-config.yml
index 9559018f8..5b48b5f30 100644
--- a/.github/ubiquibot-config.yml
+++ b/.github/ubiquibot-config.yml
@@ -1,34 +1,6 @@
----
-evm-network-id: 100
-base-multiplier: 1500
-time-labels:
- - name: "Time: <1 Hour"
- weight: 0.125
- value: 3600
- - name: "Time: <1 Day"
- weight: 1
- value: 86400
- - name: "Time: <1 Week"
- weight: 2
- value: 604800
- - name: "Time: <2 Weeks"
- weight: 3
- value: 1209600
- - name: "Time: <1 Month"
- weight: 4
- value: 2592000
-priority-labels:
- - name: "Priority: 0 (Normal)"
- weight: 1
- - name: "Priority: 1 (Medium)"
- weight: 2
- - name: "Priority: 2 (High)"
- weight: 3
- - name: "Priority: 3 (Urgent)"
- weight: 4
- - name: "Priority: 4 (Emergency)"
- weight: 5
-auto-pay-mode: true
-analytics-mode: true
-max-concurrent-bounties: 2
-promotion-comment: `\n
If you enjoy the DevPool experience, please follow Ubiquity on GitHub and star this repo to show your support. It helps a lot!
`
\ No newline at end of file
+price-multiplier: 1.5
+# newContributorGreeting:
+# enabled: true
+# header: "Thank you for contributing to UbiquiBot! Please be sure to set your wallet address before completing your first bounty so that the automatic payout upon task completion will work for you."
+# helpMenu: true
+# footer: "###### Also please star this repository and [@ubiquity/devpool-directory](https://github.com/ubiquity/devpool-directory/) to show your support. It helps a lot!"
diff --git a/.github/workflows/bot.yml b/.github/workflows/bot.yml
index c790059a3..fa5fedab5 100644
--- a/.github/workflows/bot.yml
+++ b/.github/workflows/bot.yml
@@ -81,4 +81,4 @@ jobs:
X25519_PRIVATE_KEY: 'QCDb30UHUkwJAGhLWC-R2N0PiEbd4vQY6qH2Wloybyo'
FOLLOW_UP_TIME: '4 days'
DISQUALIFY_TIME: '7 days'
- run: yarn start:serverless
\ No newline at end of file
+ run: yarn start:serverless
diff --git a/.github/workflows/conventional-commits.yml b/.github/workflows/conventional-commits.yml
index 2547809e6..6482cd5e2 100644
--- a/.github/workflows/conventional-commits.yml
+++ b/.github/workflows/conventional-commits.yml
@@ -11,4 +11,4 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- - uses: ubiquity/action-conventional-commits@v1.1.2
+ - uses: ubiquity/action-conventional-commits@master
diff --git a/.github/workflows/kebab-case.yml b/.github/workflows/kebab-case.yml
index 4fdb2ec11..b743c71c3 100644
--- a/.github/workflows/kebab-case.yml
+++ b/.github/workflows/kebab-case.yml
@@ -20,6 +20,7 @@ jobs:
"^\.\/dist"
"^\.\/build"
"^\.\/vendor"
+ "^\.\/test"
"^\.\/\.next"
"\.sql$"
"\.md$"
@@ -47,4 +48,4 @@ jobs:
echo " - $file"
done
exit 1
- fi
+ fi
\ No newline at end of file
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 000000000..2031c7a41
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,14 @@
+name: Test coveralls
+on:
+ push:
+ pull_request:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Coveralls GitHub Action
+ uses: coverallsapp/github-action@v2
+
+
+
\ No newline at end of file
diff --git a/.github/workflows/update-config.yml b/.github/workflows/update-config.yml
new file mode 100644
index 000000000..c93d04bfb
--- /dev/null
+++ b/.github/workflows/update-config.yml
@@ -0,0 +1,98 @@
+name: Pull Request Action
+permissions: write-all
+on:
+ workflow_dispatch:
+
+env:
+ GH_TOKEN: ${{ secrets.ADD_TO_PROJECT_PAT }}
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+
+ - name: Check out repository
+ uses: actions/checkout@v3
+
+ - name: Install jq and yq
+ run: |
+ sudo apt-get -y install jq
+ sudo wget https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 -O /usr/bin/yq
+ sudo chmod +x /usr/bin/yq
+
+ - name: Get UbiquiBot Token
+ uses: tibdex/github-app-token@v1.7.0
+ id: get_installation_token
+ with:
+ app_id: ${{ secrets.UBIQUITY_BOUNTY_BOT_APP_ID }}
+ private_key: ${{ secrets.UBIQUITY_BOUNTY_BOT_PRIVATE_KEY }}
+
+ - name: Update Config Params and Create Pull Requests
+ run: |
+ urls=$(curl -sSL https://raw.githubusercontent.com/ubiquity/devpool-directory/development/projects.json | jq -r '.urls[]')
+
+ for url in $urls
+ do
+ repoName=$(basename $url)
+ ownerName=$(echo $url | awk -F/ '{print $(NF-1)}')
+
+ git clone $url $repoName
+ cd $repoName
+ defaultBranch=$(git branch --show-current)
+
+ # make a branch to update config #
+ git branch update
+ git checkout update
+
+ curl -sSL https://raw.githubusercontent.com/ubiquity/ubiquibot/development/ubiquibot-config-default.json > default.json
+ declare -A param_mapping=(
+ ["evm-network-id"]="network-id chain-id"
+ ["price-multiplier"]="base-multiplier"
+ #add more configs as needed
+ )
+
+ ### update configs ###
+ # Iterate over the mapping and perform updates using sed
+ for new_param in "${!param_mapping[@]}"
+ do
+ old_params="${param_mapping[$new_param]}"
+ for old_param in $old_params
+ do
+ # only update param if the old ones exist
+ exist_old_param=$(yq "has(\"$old_param\")" .github/ubiquibot-config.yml)
+ if $exist_old_param; then
+ yq ".$new_param = .$old_param | del(.$old_param)" .github/ubiquibot-config.yml > temp.yml
+ mv temp.yml .github/ubiquibot-config.yml
+ fi
+ done
+ # if new param still doesent exist add default from ubiquibot-config-default.json
+ exist_new_param=$(yq "has(\"$new_param\")" .github/ubiquibot-config.yml)
+ if ! $exist_new_param; then
+ echo adding
+ def_val=$(jq -r ".[\"$new_param\"]" ubiquibot-config-default.json)
+ yq ".$new_param=$def_val" .github/ubiquibot-config.yml > temp.yml
+ mv temp.yml .github/ubiquibot-config.yml
+ fi
+ done
+
+ git config user.email "113181824+UbiquiBot[bot]@users.noreply.github.com"
+ git config user.name "UbiquiBot[bot]"
+
+ git add .github/ubiquibot-config.yml
+ git commit -m "build: use latest ubiquibot config setup"
+ git remote set-url origin https://${{ secrets.ADD_TO_PROJECT_PAT }}@github.com/$ownerName/$repoName.git
+ git push -f origin update
+
+ curl -L \
+ -X POST \
+ -H "Accept: application/vnd.github+json" \
+ -H "Authorization: Bearer ${{ steps.get_installation_token.outputs.token }}"\
+ -H "X-GitHub-Api-Version: 2022-11-28" \
+ https://api.github.com/repos/$ownerName/$repoName/pulls \
+ -d '{
+ "title": "build: use latest ubiquibot config setup",
+ "base": "'"$defaultBranch"'",
+ "head": "update"
+ }'
+ cd ..
+ done
\ No newline at end of file
diff --git a/README.md b/README.md
index 5ad4a1eeb..e8d09c845 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ Ubiquity DAO's GitHub Bot to automate DevPool management.
git clone https://github.com/ubiquity/ubiquibot.git
cd ubiquibot
yarn
-yarn tsc
+yarn build
yarn start:watch
```
@@ -23,6 +23,15 @@ yarn start:watch
- `LOGDNA_INGESTION_KEY`: Get it from [Memzo](https://app.mezmo.com/) by creating an account, adding an organization, and copying the ingestion key on the next screen.
- `FOLLOWUP_TIME`: (optional) Set a custom follow-up time (default: 4 days).
- `DISQUALIFY_TIME`: (optional) Set a custom disqualify time (default: 7 days).
+- `OPENAI_API_HOST`: (optional) Set OpenAI host url (default: https://api.openai.com).
+- `OPENAI_API_KEY`: Set OpenAI key.
+- `CHATGPT_USER_PROMPT_FOR_IMPORTANT_WORDS`: (optional) Set a custom user prompt for finding important words
+(default: "I need your help to find important words (e.g. unique adjectives) from github issue below and I want to parse them easily so please separate them using #(No other contexts needed). Please separate the words by # so I can parse them easily. Please answer simply as I only need the important words. Here is the issue content.\n").
+- `CHATGPT_USER_PROMPT_FOR_MEASURE_SIMILARITY`: (optional) Set a custom user prompt for measuring similarity
+(default: 'I have two github issues and I need to measure the possibility of the 2 issues are the same content (No other contents needed and give me only the number in %).\n Give me in number format and add % after the number.\nDo not tell other things since I only need the number (e.g. 85%). Here are two issues:\n 1. "%first%"\n2. "%second%"').
+- `SIMILARITY_THRESHOLD`: (optional) Set similarity threshold (default: 80).
+- `MEASURE_SIMILARITY_AI_TEMPERATURE`: (optional) Set ChatGPT temperature for measuring similarity (default: 0).
+- `IMPORTANT_WORDS_AI_TEMPERATURE`: (optional) Set ChatGPT temperature for finding important words (default: 0).
`APP_ID` and `PRIVATE_KEY` are [here](https://t.me/c/1588400061/1627) for internal developers to use.
If you are an external developer, `APP_ID`and `PRIVATE_KEY` are automatically generated when you install the app on your repository.
@@ -45,7 +54,55 @@ To test the bot, you can:
1. Create a new issue
2. Add a time label, ex: `Time: <1 Day`
-3. At this point the bot should add a price label.
+3. Add a priority label, ex: `Priority: 0 (Normal)`
+4. At this point the bot should add a price label.
+
+## Configuration
+
+`evm-network-id` is ID of the EVM-compatible network that will be used for payouts.
+
+`price-multiplier` is a base number that will be used to calculate bounty price based on the following formula: `price = price-multiplier * time-label-weight * priority-label-weight * 100`
+
+`time-labels` are labels for marking the time limit of the bounty:
+
+- `name` is a human-readable name
+- `value` is number of seconds that corresponds to the time limit of the bounty
+
+`priority-labels` are labels for marking the priority of the bounty:
+
+- `name` is a human-readable name
+
+`command-settings` are setting to enable or disable a command
+
+- `name` is the name of the command
+- `enabled` is a `true` or `false` value to enable or disable a command
+
+`default-labels` are labels that are applied when an issue is created without any time or priority labels.
+
+`assistive-pricing` to create a new pricing label if it doesn't exist. Can be `true` or `false`.
+
+`disable-analytics` can be `true` or `false` that disables or enables weekly analytics collection by Ubiquity.
+
+`payment-permit-max-price` sets the max amount for automatic payout of bounties when the issue is closed.
+
+`comment-incentives` can be `true` or `false` that enable or disable comment incentives. These are payments generated for comments in the issue by contributors, excluding the assignee.
+
+`issue-creator-multiplier` is a number that defines a base multiplier for calculating incentive for the creator of the issue.
+
+`comment-element-pricing` defines how much is a part of the comment worth. For example `text: 0.1` means that any text in the comment will add 0.1
+
+`incentives` defines incentive rewards:
+
+- `comment` defines comment rewards:
+ - `elements` defines reward value for HTML elements such as `p`, `img`, `a`.
+ - `totals`:
+ - `word` defines reward for each word in the comment
+
+`max-concurrent-assigns` is the maximum number of bounties that can be assigned to a bounty hunter at once. This excludes bounties with delayed or approved pull request reviews.
+
+`register-wallet-with-verification` can be `true` or `false`. If enabled, it requires a signed message to set wallet address. This prevents users from setting wallet address from centralized exchanges, which would make payments impossible to claim.
+
+`promotion-comment` is a message that is appended to the payment permit comment.
## How to run locally
@@ -77,7 +134,7 @@ DISQUALIFY_TIME="7 days" // 7 days
4. `yarn install`
5. Open 2 terminal instances:
- - in one instance run `yarn tsc --watch` (compiles the Typescript code)
+ - in one instance run `yarn build --watch` (compiles the Typescript code)
- in another instance run `yarn start:watch` (runs the bot locally)
6. Open `localhost:3000` and follow instructions to add the bot to one of your repositories.
@@ -88,7 +145,8 @@ You can, for example:
1. Create a new issue
2. Add a time label, ex: `Time: <1 Day`
-3. At this point the bot should add a price label, you should see event logs in one of your opened terminals
+3. Add a priority label, ex: `Priority: 0 (Normal)`
+4. At this point the bot should add a price label, you should see event logs in one of your opened terminals
## How it works
@@ -104,13 +162,31 @@ When using as a github app the flow is the following:
4. Event details are sent to your deployed bot instance (to a webhook URL that was set in github app's settings)
5. The bot handles the event
+## Payments Permits in a local instance
+
+For payment to work in your local instance, ubiquibot must be set up in a Github organization. It will not work for a ubiquibot instance set up in a personal account. Once, you have an ubiquibot instance working in an organization, follow the steps given below:
+
+1. Create a new private repository in your Github organization with name `ubiquibot-config`
+2. Add your ubiquibot app to `ubiquibot-config` repository.
+3. Create a file `.github/ubiquibot-config.yml` in it. Fill the file with contents from [this file](https://github.com/ubiquity/ubiquibot/blob/development/.github/ubiquibot-config.yml).
+4. Go to https://pay.ubq.fi/keygen and generate X25519 public/private key pair. Fill private key of your wallet's address in `PLAIN_TEXT` field and click `Encrypt`.
+5. Copy the `CIPHER_TEXT` and append it to your repo `ubiquibot-config/.github/ubiquibot-config.yml` as
+
+ `private-key-encrypted: "PASTE_YOUR_CIPHER_TEXT_HERE"`
+
+6. Copy the `X25519_PRIVATE_KEY` and append it in your local ubiquibot repository `.env` file as
+
+ `X25519_PRIVATE_KEY=PASTE_YOUR_X25519_PRIVATE_KEY_HERE`
+
## How to QA any additions to the bot
-1. Fork the ubiquibot repo and install the [ubiquibot-qa app](https://github.com/apps/ubiquibot-qa) on the forked repository.
-2. Enable github action running on the forked repo and allow `issues` on the settings tab.
-3. Create a [QA issue](https://github.com/ubiquibot/staging/issues/21) similar to this where you show the feature working in the forked repo
-4. Describe carefully the steps taken to get the feature working, this way our team can easily verify
-5. Link that QA issue to the pull request as indicated on the template before requesting a review
+Make sure you have your local instance of ubiquibot running.
+
+1. Fork the ubiquibot repo and add your local instance of ubiquibot to the forked repository.
+2. Enable Github action running on the forked repo and allow `issues` on the settings tab.
+3. Create a [QA issue](https://github.com/ubiquibot/staging/issues/21) similar to this where you show the feature working in the forked repo.
+4. Describe carefully the steps taken to get the feature working, this way our team can easily verify.
+5. Link that QA issue to the pull request as indicated on the template before requesting a review.
## How to create a new release
@@ -147,3 +223,43 @@ Bounty bot is built using the [probot](https://probot.github.io/) framework so i
|
├── utils A set of utility functions
+
+## Default Config Notes (`ubiquibot-config-default.ts`)
+
+We can't use a `jsonc` file due to limitations with Netlify. Here is a snippet of some values with notes next to them.
+
+```jsonc
+{
+ "payment-permit-max-price": 9007199254740991, // Number.MAX_SAFE_INTEGER
+ "max-concurrent-assigns": 9007199254740991, // Number.MAX_SAFE_INTEGER
+ "comment-element-pricing": {
+ /* https://github.com/syntax-tree/mdast#nodes */
+ "strong": 0 // Also includes italics, unfortunately https://github.com/syntax-tree/mdast#strong
+ /* https://github.com/syntax-tree/mdast#gfm */
+ }
+}
+```
+
+## Supabase Cron Job (`logs-cleaner`)
+
+##### Dashboard > Project > Database > Extensions
+
+> Search `PG_CRON` and Enable it.
+
+
+##### Dashboard > Project > SQL Editor
+
+```sql
+-- Runs everyday at 03:00 AM to cleanup logs that are older than a week
+-- Use the cron time format to modify the trigger time if necessary
+select
+ cron.schedule (
+ 'logs-cleaner', -- Job name
+ '0 3 * * *', -- Everyday at 03:00 AM
+ $$DELETE FROM logs WHERE timestamp < now() - INTERVAL '1 week'$$
+ );
+
+
+-- Cancel the cron job
+select cron.unschedule('logs-cleaner');
+```
diff --git a/app.yml b/app.yml
index 7c0b7f66c..8d0ae6726 100644
--- a/app.yml
+++ b/app.yml
@@ -24,7 +24,7 @@ default_events:
# - gollum
- issue_comment
- issues
- # - label
+ - label
# - milestone
# - member
# - membership
diff --git a/package.json b/package.json
index 8d05c1033..807d2253b 100644
--- a/package.json
+++ b/package.json
@@ -14,13 +14,14 @@
"build:ci": "ncc build src/adapters/github/github-actions.ts -o ./",
"build:serverless": "ncc build src/index.ts -o ./",
"build": "tsc",
+ "postbuild": "copyfiles src/assets/images/* lib/",
"clean": "rimraf ./dist ./lib ./node_modules",
"format:check": "prettier -c src/**/*.ts",
"format": "prettier --write src",
"lint": "eslint --ext .ts ./src",
- "prestart:watch": "ln -s src/assets/images lib/assets/images",
"start:serverless": "tsx src/adapters/github/github-actions.ts",
"start:watch": "nodemon --exec 'yarn start'",
+ "utils:cspell": "cspell --config .cspell.json 'src/**/*.{js,ts,json,md,yml}'",
"start": "probot run ./lib/index.js",
"prepare": "husky install"
},
@@ -28,14 +29,13 @@
"@actions/core": "^1.10.0",
"@commitlint/cli": "^17.4.3",
"@commitlint/config-conventional": "^17.4.3",
- "@logdna/logger": "^2.6.6",
"@netlify/functions": "^1.4.0",
"@probot/adapter-aws-lambda-serverless": "^3.0.2",
"@probot/adapter-github-actions": "^3.1.3",
- "@sinclair/typebox": "^0.25.9",
+ "@sinclair/typebox": "^0.31.5",
"@supabase/supabase-js": "^2.4.0",
- "@types/mdast": "^3.0.11",
"@types/ms": "^0.7.31",
+ "@types/parse5": "^7.0.0",
"@typescript-eslint/eslint-plugin": "^5.59.11",
"@typescript-eslint/parser": "^5.59.11",
"@uniswap/permit2-sdk": "^1.2.0",
@@ -43,19 +43,24 @@
"ajv": "^8.11.2",
"ajv-formats": "^2.1.1",
"axios": "^1.3.2",
+ "copyfiles": "^2.4.1",
+ "cspell": "^7.0.0",
+ "decimal.js": "^10.4.3",
"ethers": "^5.7.2",
+ "exponential-backoff": "^3.1.1",
"husky": "^8.0.2",
"jimp": "^0.22.4",
"js-yaml": "^4.1.0",
+ "jsonwebtoken": "^9.0.2",
"libsodium-wrappers": "^0.7.11",
"lint-staged": "^13.1.0",
- "mdast-util-from-markdown": "^1.3.0",
- "mdast-util-gfm": "^2.0.2",
- "micromark-extension-gfm": "^2.0.3",
+ "lodash": "^4.17.21",
"ms": "^2.1.3",
"node-html-parser": "^6.1.5",
"node-html-to-image": "^3.3.0",
"nodemon": "^2.0.19",
+ "openai": "^4.2.0",
+ "parse5": "^7.1.2",
"prettier": "^2.7.1",
"probot": "^12.2.4",
"telegraf": "^4.11.2",
@@ -66,6 +71,7 @@
"@types/eslint": "^8.40.2",
"@types/jest": "^28.1.0",
"@types/libsodium-wrappers": "^0.7.10",
+ "@types/lodash": "^4.14.197",
"@types/node": "^14.18.37",
"@types/source-map-support": "^0.5.6",
"eslint": "^8.43.0",
@@ -84,6 +90,9 @@
"lint-staged": {
"*.{ts,json}": [
"prettier --write"
+ ],
+ "src/**.{ts,json}": [
+ "cspell"
]
},
"nodemonConfig": {
diff --git a/src/adapters/index.ts b/src/adapters/index.ts
index c5d85b7eb..5279a354a 100644
--- a/src/adapters/index.ts
+++ b/src/adapters/index.ts
@@ -6,7 +6,7 @@ export * from "./telegram";
export const createAdapters = (config: BotConfig): Adapters => {
return {
- supabase: supabase(config.supabase.url, config.supabase.key),
- telegram: new Telegraf(config.telegram.token).telegram,
+ supabase: supabase(config?.supabase?.url ?? process.env.SUPABASE_URL, config?.supabase?.key ?? process.env.SUPABASE_KEY),
+ telegram: new Telegraf(config?.telegram?.token ?? process.env.TELEGRAM_BOT_TOKEN).telegram,
};
};
diff --git a/src/adapters/supabase/helpers/client.ts b/src/adapters/supabase/helpers/client.ts
index cfa3d68d9..b8a3b23da 100644
--- a/src/adapters/supabase/helpers/client.ts
+++ b/src/adapters/supabase/helpers/client.ts
@@ -2,6 +2,15 @@ import { createClient, SupabaseClient } from "@supabase/supabase-js";
import { getAdapters, getLogger } from "../../../bindings";
import { Issue, UserProfile } from "../../../types";
import { Database } from "../types";
+import { InsertPermit, Permit } from "../../../helpers";
+import { BigNumber, BigNumberish } from "ethers";
+
+interface AccessLevels {
+ multiplier: boolean;
+ price: boolean;
+ priority: boolean;
+ time: boolean;
+}
/**
* @dev Creates a typescript client which will be used to interact with supabase platform
@@ -11,7 +20,7 @@ import { Database } from "../types";
* @returns - The supabase client
*/
export const supabase = (url: string, key: string): SupabaseClient => {
- return createClient(url, key);
+ return createClient(url, key, { auth: { persistSession: false } });
};
/**
@@ -31,25 +40,45 @@ export const getMaxIssueNumber = async (): Promise => {
/**
* @dev Gets the last weekly update timestamp
*/
-export const getLastWeeklyTime = async (): Promise => {
+export const getLastWeeklyTime = async (): Promise => {
const { supabase } = getAdapters();
const { data } = await supabase.from("weekly").select("last_time").limit(1).single();
if (data) {
- return Number(data.last_time);
+ return new Date(data.last_time);
} else {
- return 0;
+ return undefined;
}
};
/**
* @dev Updates the last weekly update timestamp
*/
-export const updateLastWeeklyTime = async (time: number): Promise => {
+export const updateLastWeeklyTime = async (time: Date): Promise => {
const logger = getLogger();
const { supabase } = getAdapters();
- const { data, error } = await supabase.from("weekly").update({ last_time: time });
- logger.info(`Updating last time is done, data: ${data}, error: ${error}`);
+
+ const { data, error } = await supabase.from("weekly").select("last_time");
+ if (error) {
+ logger.error(`Checking last time failed, error: ${JSON.stringify(error)}`);
+ throw new Error(`Checking last time failed, error: ${JSON.stringify(error)}`);
+ }
+
+ if (data && data.length > 0) {
+ const { data, error } = await supabase.from("weekly").update({ last_time: time.toUTCString() }).neq("last_time", time.toUTCString());
+ if (error) {
+ logger.error(`Updating last time failed, error: ${JSON.stringify(error)}`);
+ throw new Error(`Updating last time failed, error: ${JSON.stringify(error)}`);
+ }
+ logger.info(`Updating last time is done, data: ${data}`);
+ } else {
+ const { data, error } = await supabase.from("weekly").insert({ last_time: time.toUTCString() });
+ if (error) {
+ logger.error(`Creating last time failed, error: ${JSON.stringify(error)}`);
+ throw new Error(`Creating last time failed, error: ${JSON.stringify(error)}`);
+ }
+ logger.info(`Creating last time is done, data: ${data}`);
+ }
return;
};
@@ -90,7 +119,7 @@ const getDbDataFromUserProfile = (userProfile: UserProfile, additions?: UserProf
return {
user_login: userProfile.login,
user_type: userProfile.type,
- user_name: userProfile.name,
+ user_name: userProfile.name ?? userProfile.login,
company: userProfile.company,
blog: userProfile.blog,
user_location: userProfile.location,
@@ -112,18 +141,30 @@ const getDbDataFromUserProfile = (userProfile: UserProfile, additions?: UserProf
export const upsertIssue = async (issue: Issue, additions: IssueAdditions): Promise => {
const logger = getLogger();
const { supabase } = getAdapters();
- const { data, error } = await supabase.from("issues").select("id").eq("issue_number", issue.number).single();
+ const { data, error } = await supabase.from("issues").select("id").eq("issue_number", issue.number);
+ if (error) {
+ logger.error(`Checking issue failed, error: ${JSON.stringify(error)}`);
+ throw new Error(`Checking issue failed, error: ${JSON.stringify(error)}`);
+ }
- if (data) {
- const key = data.id as number;
- await supabase
+ if (data && data.length > 0) {
+ const key = data[0].id as number;
+ const { data: _data, error: _error } = await supabase
.from("issues")
.upsert({ id: key, ...getDbDataFromIssue(issue, additions) })
.select();
- logger.info(`Upserting an issue done, data: ${data}, error: ${error}`);
- } else if (error) {
+ if (_error) {
+ logger.error(`Upserting an issue failed, error: ${JSON.stringify(_error)}`);
+ throw new Error(`Upserting an issue failed, error: ${JSON.stringify(_error)}`);
+ }
+ logger.info(`Upserting an issue done, { data: ${_data}, error: ${_error}`);
+ } else {
const { data: _data, error: _error } = await supabase.from("issues").insert(getDbDataFromIssue(issue, additions));
- logger.info(`Creating a new issue done, { data: ${_data}, error: ${_error}`);
+ if (_error) {
+ logger.error(`Creating a new issue record failed, error: ${JSON.stringify(_error)}`);
+ throw new Error(`Creating a new issue record failed, error: ${JSON.stringify(_error)}`);
+ }
+ logger.info(`Creating a new issue record done, { data: ${_data}, error: ${_error}`);
}
};
@@ -134,18 +175,26 @@ export const upsertIssue = async (issue: Issue, additions: IssueAdditions): Prom
export const upsertUser = async (user: UserProfile): Promise => {
const logger = getLogger();
const { supabase } = getAdapters();
- const { data, error } = await supabase.from("users").select("id").eq("user_login", user.login).single();
+ const { data, error } = await supabase.from("users").select("user_login").eq("user_login", user.login);
+ if (error) {
+ logger.error(`Checking user failed, error: ${JSON.stringify(error)}`);
+ throw new Error(`Checking user failed, error: ${JSON.stringify(error)}`);
+ }
- if (data) {
- const key = data.id as number;
- await supabase
- .from("users")
- .upsert({ id: key, ...getDbDataFromUserProfile(user) })
- .select();
- logger.info(`Upserting an user done", { data: ${data}, error: ${error} }`);
- } else if (error) {
+ if (data && data.length > 0) {
+ const { data: _data, error: _error } = await supabase.from("users").upsert(getDbDataFromUserProfile(user)).select();
+ if (_error) {
+ logger.error(`Upserting a user failed, error: ${JSON.stringify(_error)}`);
+ throw new Error(`Upserting a user failed, error: ${JSON.stringify(_error)}`);
+ }
+ logger.info(`Upserting a user done, { data: ${JSON.stringify(_data)} }`);
+ } else {
const { data: _data, error: _error } = await supabase.from("users").insert(getDbDataFromUserProfile(user));
- logger.info(`Creating a new user done", { data: ${_data}, error: ${_error} }`);
+ if (_error) {
+ logger.error(`Creating a new user record failed, error: ${JSON.stringify(_error)}`);
+ throw new Error(`Creating a new user record failed, error: ${JSON.stringify(_error)}`);
+ }
+ logger.info(`Creating a new user record done, { data: ${JSON.stringify(_data)} }`);
}
};
@@ -158,53 +207,78 @@ export const upsertWalletAddress = async (username: string, address: string): Pr
const logger = getLogger();
const { supabase } = getAdapters();
- const { data, error } = await supabase.from("wallets").select("user_name").eq("user_name", username).single();
- if (data) {
- await supabase.from("wallets").upsert({
+ const { data, error } = await supabase.from("wallets").select("user_name").eq("user_name", username);
+ if (error) {
+ logger.error(`Checking wallet address failed, error: ${JSON.stringify(error)}`);
+ throw new Error(`Checking wallet address failed, error: ${JSON.stringify(error)}`);
+ }
+
+ if (data && data.length > 0) {
+ const { data: _data, error: _error } = await supabase.from("wallets").upsert({
user_name: username,
wallet_address: address,
updated_at: new Date().toUTCString(),
});
- logger.info(`Upserting a wallet address done, { data: ${data}, error: ${error} }`);
+ if (_error) {
+ logger.error(`Upserting a wallet address failed, error: ${JSON.stringify(_error)}`);
+ throw new Error(`Upserting a wallet address failed, error: ${JSON.stringify(_error)}`);
+ }
+ logger.info(`Upserting a wallet address done, { data: ${JSON.stringify(_data)} }`);
} else {
- const { data: _data, error: _error } = await supabase.from("wallets").insert({
+ const { error } = await supabase.from("wallets").insert({
user_name: username,
wallet_address: address,
created_at: new Date().toUTCString(),
updated_at: new Date().toUTCString(),
});
- logger.info(`Creating a new wallet_table record done, { data: ${_data}, error: ${_error} }`);
+ if (error) {
+ logger.error(`Creating a new wallet_table record failed, error: ${JSON.stringify(error)}`);
+ throw new Error(`Creating a new wallet_table record failed, error: ${JSON.stringify(error)}`);
+ }
+ logger.info(`Creating a new wallet_table record done, { data: ${JSON.stringify(data)}, address: $address }`);
}
};
/**
- * Performs an UPSERT on the wallet table.
+ * Performs an UPSERT on the multiplier table.
* @param username The user name you want to upsert a wallet address for
* @param address The account multiplier
*/
-export const upsertWalletMultiplier = async (username: string, multiplier: string, reason: string): Promise => {
+export const upsertWalletMultiplier = async (username: string, multiplier: string, reason: string, org_id: string): Promise => {
const logger = getLogger();
const { supabase } = getAdapters();
- const { data, error } = await supabase.from("wallets").select("user_name").eq("user_name", username).single();
- if (data) {
- await supabase.from("wallets").upsert({
- user_name: username,
- multiplier,
+ const { data, error } = await supabase.from("multiplier").select("user_id").eq("user_id", `${username}_${org_id}`);
+ if (error) {
+ logger.error(`Checking wallet multiplier failed, error: ${JSON.stringify(error)}`);
+ throw new Error(`Checking wallet multiplier failed, error: ${JSON.stringify(error)}`);
+ }
+
+ if (data && data.length > 0) {
+ const { data: _data, error: _error } = await supabase.from("multiplier").upsert({
+ user_id: `${username}_${org_id}`,
+ value: multiplier,
reason,
updated_at: new Date().toUTCString(),
});
- logger.info(`Upserting a wallet address done, { data: ${data}, error: ${error} }`);
+ if (_error) {
+ logger.error(`Upserting a wallet multiplier failed, error: ${JSON.stringify(_error)}`);
+ throw new Error(`Upserting a wallet multiplier failed, error: ${JSON.stringify(_error)}`);
+ }
+ logger.info(`Upserting a wallet multiplier done, { data: ${JSON.stringify(_data)} }`);
} else {
- const { data: _data, error: _error } = await supabase.from("wallets").insert({
- user_name: username,
- wallet_address: "",
- multiplier,
+ const { data: _data, error: _error } = await supabase.from("multiplier").insert({
+ user_id: `${username}_${org_id}`,
+ value: multiplier,
reason,
created_at: new Date().toUTCString(),
updated_at: new Date().toUTCString(),
});
- logger.info(`Creating a new wallet_table record done, { data: ${_data}, error: ${_error} }`);
+ if (_error) {
+ logger.error(`Creating a new multiplier record failed, error: ${JSON.stringify(_error)}`);
+ throw new Error(`Creating a new multiplier record failed, error: ${JSON.stringify(_error)}`);
+ }
+ logger.info(`Creating a new multiplier record done, { data: ${JSON.stringify(_data)} }`);
}
};
@@ -219,7 +293,11 @@ export const upsertAccessControl = async (username: string, repository: string,
const logger = getLogger();
const { supabase } = getAdapters();
- const { data, error } = await supabase.from("access").select("user_name").eq("user_name", username).eq("repository", repository).single();
+ const { data, error } = await supabase.from("access").select("user_name").eq("user_name", username).eq("repository", repository);
+ if (error) {
+ logger.error(`Checking access control failed, error: ${JSON.stringify(error)}`);
+ throw new Error(`Checking access control failed, error: ${JSON.stringify(error)}`);
+ }
const properties = {
user_name: username,
@@ -228,19 +306,27 @@ export const upsertAccessControl = async (username: string, repository: string,
[access]: bool,
};
- if (data) {
- await supabase.from("access").upsert(properties);
- logger.info(`Upserting an access done, { data: ${data}, error: ${error} }`);
+ if (data && data.length > 0) {
+ const { data: _data, error: _error } = await supabase.from("access").upsert(properties);
+ if (_error) {
+ logger.error(`Upserting a access control failed, error: ${JSON.stringify(_error)}`);
+ throw new Error(`Upserting a access control failed, error: ${JSON.stringify(_error)}`);
+ }
+ logger.info(`Upserting a access control done, { data: ${JSON.stringify(_data)} }`);
} else {
const { data: _data, error: _error } = await supabase.from("access").insert({
created_at: new Date().toUTCString(),
price_access: false,
- time_access: false,
+ time_access: true,
multiplier_access: false,
priority_access: false,
...properties,
});
- logger.info(`Creating a new access record done, { data: ${_data}, error: ${_error} }`);
+ if (_error) {
+ logger.error(`Creating a new access control record failed, error: ${JSON.stringify(_error)}`);
+ throw new Error(`Creating a new access control record failed, error: ${JSON.stringify(_error)}`);
+ }
+ logger.info(`Creating a new access control record done, { data: ${JSON.stringify(_data)} }`);
}
};
@@ -261,6 +347,20 @@ export const getAccessLevel = async (username: string, repository: string, label
return accessValues;
};
+export const getAllAccessLevels = async (username: string, repository: string): Promise => {
+ const logger = getLogger();
+ const { supabase } = getAdapters();
+
+ const { data } = await supabase.from("access").select("*").eq("user_name", username).eq("repository", repository).single();
+
+ if (!data) {
+ logger.info(`Access not found on the database`);
+ // no access
+ return null;
+ }
+ return { multiplier: data.multiplier_access, time: data.time_access, priority: data.priority_access, price: data.price_access };
+};
+
/**
* Queries the wallet address registered previously
*
@@ -282,16 +382,194 @@ export const getWalletAddress = async (username: string): Promise => {
+export const getWalletMultiplier = async (username: string, org_id: string): Promise<{ value: number; reason: string }> => {
+ const { supabase } = getAdapters();
+
+ const { data } = await supabase.from("multiplier").select("value, reason").eq("user_id", `${username}_${org_id}`).single();
+ if (data?.value == null) return { value: 1, reason: "" };
+ else return { value: data?.value, reason: data?.reason };
+};
+
+/**
+ * Queries both the wallet multiplier and address in one request registered previously
+ *
+ * @param username The username you want to find an address for
+ * @returns The Multiplier and ERC-20 Address, returns 1 if not found
+ *
+ */
+
+export const getWalletInfo = async (username: string, org_id: string): Promise<{ multiplier: number | null; address: string | null }> => {
+ const { supabase } = getAdapters();
+
+ const { data: wallet } = await supabase.from("wallets").select("wallet_address").eq("user_name", username).single();
+ const { data: multiplier } = await supabase.from("multiplier").select("value").eq("user_id", `${username}_${org_id}`).single();
+ if (multiplier?.value == null) {
+ return { multiplier: 1, address: wallet?.wallet_address || "" };
+ } else return { multiplier: multiplier?.value, address: wallet?.wallet_address };
+};
+
+export const addPenalty = async (username: string, repoName: string, tokenAddress: string, networkId: string, penalty: BigNumberish): Promise => {
+ const { supabase } = getAdapters();
+ const logger = getLogger();
+
+ const { error } = await supabase.rpc("add_penalty", {
+ _username: username,
+ _repository_name: repoName,
+ _token_address: tokenAddress,
+ _network_id: networkId,
+ _penalty_amount: penalty.toString(),
+ });
+ logger.debug(`Adding penalty done, { data: ${JSON.stringify(error)}, error: ${JSON.stringify(error)} }`);
+
+ if (error) {
+ throw new Error(`Error adding penalty: ${error.message}`);
+ }
+};
+
+export const getPenalty = async (username: string, repoName: string, tokenAddress: string, networkId: string): Promise => {
+ const { supabase } = getAdapters();
+ const logger = getLogger();
+
+ const { data, error } = await supabase
+ .from("penalty")
+ .select("amount")
+ .eq("username", username)
+ .eq("repository_name", repoName)
+ .eq("network_id", networkId)
+ .eq("token_address", tokenAddress);
+ logger.debug(`Getting penalty done, { data: ${JSON.stringify(error)}, error: ${JSON.stringify(error)} }`);
+
+ if (error) {
+ throw new Error(`Error getting penalty: ${error.message}`);
+ }
+
+ if (data.length === 0) {
+ return BigNumber.from(0);
+ }
+ return BigNumber.from(data[0].amount);
+};
+
+export const removePenalty = async (username: string, repoName: string, tokenAddress: string, networkId: string, penalty: BigNumberish): Promise => {
+ const { supabase } = getAdapters();
+ const logger = getLogger();
+
+ const { error } = await supabase.rpc("remove_penalty", {
+ _username: username,
+ _repository_name: repoName,
+ _network_id: networkId,
+ _token_address: tokenAddress,
+ _penalty_amount: penalty.toString(),
+ });
+ logger.debug(`Removing penalty done, { data: ${JSON.stringify(error)}, error: ${JSON.stringify(error)} }`);
+
+ if (error) {
+ throw new Error(`Error removing penalty: ${error.message}`);
+ }
+};
+
+const getDbDataFromPermit = (permit: InsertPermit): Record => {
+ return {
+ organization_id: permit.organizationId,
+ repository_id: permit.repositoryId,
+ issue_id: permit.issueId,
+ network_id: permit.networkId,
+ bounty_hunter_id: permit.bountyHunterId,
+ token_address: permit.tokenAddress,
+ payout_amount: permit.payoutAmount,
+ bounty_hunter_address: permit.bountyHunterAddress,
+ nonce: permit.nonce,
+ deadline: permit.deadline,
+ signature: permit.signature,
+ wallet_owner_address: permit.walletOwnerAddress,
+ };
+};
+
+const getPermitFromDbData = (data: Record): Permit => {
+ return {
+ id: data.id,
+ createdAt: new Date(Date.parse(data.created_at as string)),
+ organizationId: data.organization_id,
+ repositoryId: data.repository_i,
+ issueId: data.issue_id,
+ networkId: data.network_id,
+ bountyHunterId: data.bounty_hunter_id,
+ tokenAddress: data.token_address,
+ payoutAmount: data.payout_amount,
+ bountyHunterAddress: data.bounty_hunter_address,
+ nonce: data.nonce,
+ deadline: data.deadline,
+ signature: data.signature,
+ walletOwnerAddress: data.wallet_owner_address,
+ } as Permit;
+};
+
+export const savePermit = async (permit: InsertPermit): Promise => {
+ const { supabase } = getAdapters();
+ const { data, error } = await supabase
+ .from("permits")
+ .insert({
+ ...getDbDataFromPermit(permit),
+ created_at: new Date().toISOString(),
+ id: undefined, // id is auto-generated
+ })
+ .select();
+ if (error) {
+ throw new Error(error.message);
+ }
+ if (!data || data.length === 0) {
+ throw new Error("No data returned");
+ }
+ return getPermitFromDbData(data[0]);
+};
+
+export const saveLabelChange = async (username: string, repository: string, label_from: string, label_to: string, hasAccess: boolean) => {
+ const { supabase } = getAdapters();
+ const { data, error } = await supabase
+ .from("label_changes")
+ .insert({
+ username,
+ repository,
+ label_from,
+ label_to,
+ authorized: hasAccess || false,
+ created: new Date().toISOString(),
+ updated: new Date().toISOString(),
+ })
+ .select();
+ if (error) {
+ throw new Error(error.message);
+ }
+ if (!data || data.length === 0) {
+ throw new Error("No data returned");
+ }
+ return data[0];
+};
+
+export const getLabelChanges = async (repository: string, labels: string[]) => {
const { supabase } = getAdapters();
+ const logger = getLogger();
+
+ const { data, error } = await supabase.from("label_changes").select("*").in("label_to", labels).eq("repository", repository).eq("authorized", false);
+
+ logger.debug(`Getting label changes done, { data: ${JSON.stringify(data)}, error: ${JSON.stringify(error)} }`);
+
+ if (error) {
+ throw new Error(`Error getting label changes: ${error.message}`);
+ }
- const { data } = await supabase.from("wallets").select("multiplier").eq("user_name", username).single();
- if (data?.multiplier == null) return 1;
- else return data?.multiplier;
+ if (data.length === 0) {
+ return null;
+ }
+ return data[0];
};
-export const getMultiplierReason = async (username: string): Promise => {
+export const _approveLabelChange = async (changeId: number) => {
const { supabase } = getAdapters();
- const { data } = await supabase.from("wallets").select("reason").eq("user_name", username).single();
- return data?.reason;
+ const { error } = await supabase.from("label_changes").update({ authorized: true }).eq("id", changeId);
+
+ if (error) {
+ throw new Error(error.message);
+ }
+
+ return;
};
diff --git a/src/adapters/supabase/helpers/index.ts b/src/adapters/supabase/helpers/index.ts
index 5ec76921e..3bb7e5e98 100644
--- a/src/adapters/supabase/helpers/index.ts
+++ b/src/adapters/supabase/helpers/index.ts
@@ -1 +1,2 @@
export * from "./client";
+export * from "./log";
diff --git a/src/adapters/supabase/helpers/log.ts b/src/adapters/supabase/helpers/log.ts
new file mode 100644
index 000000000..81bbf9d6b
--- /dev/null
+++ b/src/adapters/supabase/helpers/log.ts
@@ -0,0 +1,281 @@
+import axios from "axios";
+import { getAdapters, getBotContext, Logger } from "../../../bindings";
+import { Payload, LogLevel, LogNotification } from "../../../types";
+import { getOrgAndRepoFromPath } from "../../../utils/private";
+import jwt from "jsonwebtoken";
+interface Log {
+ repo: string | null;
+ org: string | null;
+ commentId: number | undefined;
+ issueNumber: number | undefined;
+ logMessage: string;
+ level: LogLevel;
+ timestamp: string;
+}
+
+export const getNumericLevel = (level: LogLevel) => {
+ switch (level) {
+ case LogLevel.ERROR:
+ return 0;
+ case LogLevel.WARN:
+ return 1;
+ case LogLevel.INFO:
+ return 2;
+ case LogLevel.HTTP:
+ return 3;
+ case LogLevel.VERBOSE:
+ return 4;
+ case LogLevel.DEBUG:
+ return 5;
+ case LogLevel.SILLY:
+ return 6;
+ default:
+ return -1; // Invalid level
+ }
+};
+
+export class GitHubLogger implements Logger {
+ private supabase;
+ private maxLevel;
+ private app;
+ private logEnvironment;
+ private logQueue: Log[] = []; // Your log queue
+ private maxConcurrency = 6; // Maximum concurrent requests
+ private retryDelay = 1000; // Delay between retries in milliseconds
+ private throttleCount = 0;
+ private retryLimit = 0; // Retries disabled by default
+ private logNotification;
+
+ constructor(app: string, logEnvironment: string, maxLevel: LogLevel, retryLimit: number, logNotification: LogNotification) {
+ this.app = app;
+ this.logEnvironment = logEnvironment;
+ this.maxLevel = getNumericLevel(maxLevel);
+ this.retryLimit = retryLimit;
+ this.supabase = getAdapters().supabase;
+ this.logNotification = logNotification;
+ }
+
+ async sendLogsToSupabase({ repo, org, commentId, issueNumber, logMessage, level, timestamp }: Log) {
+ const { error } = await this.supabase.from("logs").insert([
+ {
+ repo_name: repo,
+ level: getNumericLevel(level),
+ org_name: org,
+ comment_id: commentId,
+ log_message: logMessage,
+ issue_number: issueNumber,
+ timestamp,
+ },
+ ]);
+
+ if (error) {
+ console.error("Error logging to Supabase:", error.message);
+ return;
+ }
+ }
+
+ async processLogs(log: Log) {
+ try {
+ await this.sendLogsToSupabase(log);
+ } catch (error) {
+ console.error("Error sending log, retrying:", error);
+ return this.retryLimit > 0 ? await this.retryLog(log) : null;
+ }
+ }
+
+ private sendDataWithJwt(message: string | object, errorPayload?: string | object) {
+ const context = getBotContext();
+ const payload = context.payload as Payload;
+
+ const { comment, issue, repository } = payload;
+ const commentId = comment?.id;
+ const issueNumber = issue?.number;
+ const repoFullName = repository?.full_name;
+
+ const { org, repo } = getOrgAndRepoFromPath(repoFullName);
+
+ const issueLink = `https://github.com/${org}/${repo}/issues/${issueNumber}${commentId ? `#issuecomment-${commentId}` : ""}`;
+
+ return new Promise((resolve, reject) => {
+ try {
+ if (!this.logNotification?.enabled) {
+ reject("Telegram Log Notification is disabled, please check that url, secret and group is provided");
+ }
+
+ if (typeof message === "object") {
+ message = JSON.stringify(message);
+ }
+
+ if (errorPayload && typeof errorPayload === "object") {
+ errorPayload = JSON.stringify(errorPayload);
+ }
+
+ const errorMessage = `\`${message}${errorPayload ? " - " + errorPayload : ""}\`\n\nContext: ${issueLink}`;
+
+ // Step 1: Sign a JWT with the provided parameter
+ const jwtToken = jwt.sign(
+ {
+ group: this.logNotification.groupId,
+ topic: this.logNotification.topicId,
+ msg: errorMessage,
+ },
+ this.logNotification.secret,
+ { noTimestamp: true }
+ );
+
+ const apiUrl = `${this.logNotification.url}/sendLogs`;
+ const headers = {
+ Authorization: `${jwtToken}`,
+ };
+
+ axios
+ .get(apiUrl, { headers })
+ .then((response) => {
+ resolve(response.data);
+ })
+ .catch((error) => {
+ reject(error);
+ });
+ } catch (error) {
+ // Reject the promise with the error
+ reject(error);
+ }
+ });
+ }
+
+ async retryLog(log: Log, retryCount = 0) {
+ if (retryCount >= this.retryLimit) {
+ console.error("Max retry limit reached for log:", log);
+ return;
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, this.retryDelay));
+
+ try {
+ await this.sendLogsToSupabase(log);
+ } catch (error) {
+ console.error("Error sending log (after retry):", error);
+ await this.retryLog(log, retryCount + 1);
+ }
+ }
+
+ async processLogQueue() {
+ while (this.logQueue.length > 0) {
+ const log = this.logQueue.shift();
+ if (!log) {
+ continue;
+ }
+ await this.processLogs(log);
+ }
+ }
+
+ async throttle() {
+ if (this.throttleCount >= this.maxConcurrency) {
+ return;
+ }
+
+ this.throttleCount++;
+ try {
+ await this.processLogQueue();
+ } finally {
+ this.throttleCount--;
+ if (this.logQueue.length > 0) {
+ await this.throttle();
+ }
+ }
+ }
+
+ async addToQueue(log: Log) {
+ this.logQueue.push(log);
+ if (this.throttleCount < this.maxConcurrency) {
+ await this.throttle();
+ }
+ }
+
+ private save(logMessage: string | object, level: LogLevel, errorPayload?: string | object) {
+ if (getNumericLevel(level) > this.maxLevel) return; // only return errors lower than max level
+
+ const context = getBotContext();
+ const payload = context.payload as Payload;
+ const timestamp = new Date().toUTCString();
+
+ const { comment, issue, repository } = payload;
+ const commentId = comment?.id;
+ const issueNumber = issue?.number;
+ const repoFullName = repository?.full_name;
+
+ const { org, repo } = getOrgAndRepoFromPath(repoFullName);
+
+ if (!logMessage) return;
+
+ if (typeof logMessage === "object") {
+ // pass log as json stringified
+ logMessage = JSON.stringify(logMessage);
+ }
+
+ this.addToQueue({ repo, org, commentId, issueNumber, logMessage, level, timestamp })
+ .then(() => {
+ return;
+ })
+ .catch(() => {
+ console.log("Error adding logs to queue");
+ });
+
+ if (this.logEnvironment === "development") {
+ console.log(this.app, logMessage, errorPayload, level, repo, org, commentId, issueNumber);
+ }
+ }
+
+ info(message: string | object, errorPayload?: string | object) {
+ this.save(message, LogLevel.INFO, errorPayload);
+ }
+
+ warn(message: string | object, errorPayload?: string | object) {
+ this.save(message, LogLevel.WARN, errorPayload);
+ this.sendDataWithJwt(message, errorPayload)
+ .then((response) => {
+ this.save(`Log Notification Success: ${response}`, LogLevel.DEBUG, "");
+ })
+ .catch((error) => {
+ this.save(`Log Notification Error: ${error}`, LogLevel.DEBUG, "");
+ });
+ }
+
+ debug(message: string | object, errorPayload?: string | object) {
+ this.save(message, LogLevel.DEBUG, errorPayload);
+ }
+
+ error(message: string | object, errorPayload?: string | object) {
+ this.save(message, LogLevel.ERROR, errorPayload);
+ this.sendDataWithJwt(message, errorPayload)
+ .then((response) => {
+ this.save(`Log Notification Success: ${response}`, LogLevel.DEBUG, "");
+ })
+ .catch((error) => {
+ this.save(`Log Notification Error: ${error}`, LogLevel.DEBUG, "");
+ });
+ }
+
+ async get() {
+ try {
+ const { data, error } = await this.supabase.from("logs").select("*");
+
+ if (error) {
+ console.error("Error retrieving logs from Supabase:", error.message);
+ return [];
+ }
+
+ return data;
+ } catch (error) {
+ if (error instanceof Error) {
+ // 👉️ err is type Error here
+ console.error("An error occurred:", error.message);
+
+ return;
+ }
+
+ console.log("Unexpected error", error);
+ return [];
+ }
+ }
+}
diff --git a/src/adapters/supabase/index.ts b/src/adapters/supabase/index.ts
index 872ef39cc..d4e09d7b4 100644
--- a/src/adapters/supabase/index.ts
+++ b/src/adapters/supabase/index.ts
@@ -1,2 +1 @@
export * from "./helpers";
-export * from "./types";
diff --git a/src/adapters/supabase/types/database.d.ts b/src/adapters/supabase/types/database.ts
similarity index 96%
rename from src/adapters/supabase/types/database.d.ts
rename to src/adapters/supabase/types/database.ts
index 48a6256bf..d2a8ce03e 100644
--- a/src/adapters/supabase/types/database.d.ts
+++ b/src/adapters/supabase/types/database.ts
@@ -240,7 +240,24 @@ export interface Database {
[_ in never]: never;
};
Functions: {
- [_ in never]: never;
+ add_penalty: {
+ Args: {
+ username: string;
+ repository_name: string;
+ token_address: string;
+ penalty_amount: string;
+ };
+ Returns: string;
+ };
+ deduct_penalty: {
+ Args: {
+ username: string;
+ repository_name: string;
+ token_address: string;
+ penalty_amount: string;
+ };
+ Returns: string;
+ };
};
Enums: {
issue_status: "READY_TO_START" | "IN_PROGRESS" | "IN_REVIEW" | "DONE";
diff --git a/src/adapters/supabase/types/index.ts b/src/adapters/supabase/types/index.ts
index f211e1c1a..c30cd664d 100644
--- a/src/adapters/supabase/types/index.ts
+++ b/src/adapters/supabase/types/index.ts
@@ -1 +1 @@
-export * from "./database.d";
+export * from "./database";
diff --git a/src/assets/fonts/proxima-nova-regular-b64.ts b/src/assets/fonts/proxima-nova-regular-b64.ts
index 6f659f5f2..e558fa5e0 100644
--- a/src/assets/fonts/proxima-nova-regular-b64.ts
+++ b/src/assets/fonts/proxima-nova-regular-b64.ts
@@ -1 +1,2 @@
+// cspell:disable
export const ProximaNovaRegularBase64 = `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`;
diff --git a/src/bindings/config.ts b/src/bindings/config.ts
index 60e0eb5dd..55d0fb8be 100644
--- a/src/bindings/config.ts
+++ b/src/bindings/config.ts
@@ -1,7 +1,6 @@
import ms from "ms";
-import { BotConfig, BotConfigSchema } from "../types";
-import { DEFAULT_BOT_DELAY, DEFAULT_DISQUALIFY_TIME, DEFAULT_FOLLOWUP_TIME, DEFAULT_PERMIT_BASE_URL } from "../configs";
+import { BotConfig, BotConfigSchema, LogLevel } from "../types";
import { getPayoutConfigByNetworkId } from "../helpers";
import { ajv } from "../utils";
import { Context } from "probot";
@@ -9,18 +8,33 @@ import { getScalarKey, getWideConfig } from "../utils/private";
export const loadConfig = async (context: Context): Promise => {
const {
- privateKey,
baseMultiplier,
timeLabels,
+ privateKey,
priorityLabels,
- commentElementPricing,
- autoPayMode,
- analyticsMode,
+ incentives,
+ paymentPermitMaxPrice,
+ disableAnalytics,
bountyHunterMax,
incentiveMode,
networkId,
issueCreatorMultiplier,
+ defaultLabels,
promotionComment,
+ commandSettings,
+ assistivePricing,
+ registerWalletWithVerification,
+ staleBountyTime,
+ enableAccessControl,
+ openAIKey,
+ openAITokenLimit,
+ newContributorGreeting,
+ timeRangeForMaxIssueEnabled,
+ timeRangeForMaxIssue,
+ permitBaseUrl,
+ botDelay,
+ followUpTime,
+ disqualifyTime,
} = await getWideConfig(context);
const publicKey = await getScalarKey(process.env.X25519_PRIVATE_KEY);
@@ -28,15 +42,17 @@ export const loadConfig = async (context: Context): Promise => {
const botConfig: BotConfig = {
log: {
- level: process.env.LOG_LEVEL || "debug",
- ingestionKey: process.env.LOGDNA_INGESTION_KEY ?? "",
+ logEnvironment: process.env.LOG_ENVIRONMENT || "production",
+ level: (process.env.LOG_LEVEL as LogLevel) || LogLevel.DEBUG,
+ retryLimit: Number(process.env.LOG_RETRY) || 0,
},
price: {
baseMultiplier,
issueCreatorMultiplier,
timeLabels,
priorityLabels,
- commentElementPricing,
+ incentives,
+ defaultLabels,
},
comments: {
promotionComment: promotionComment,
@@ -46,11 +62,17 @@ export const loadConfig = async (context: Context): Promise => {
rpc: rpc,
privateKey: privateKey,
paymentToken: paymentToken,
- permitBaseUrl: process.env.PERMIT_BASE_URL || DEFAULT_PERMIT_BASE_URL,
+ permitBaseUrl: process.env.PERMIT_BASE_URL || permitBaseUrl,
},
unassign: {
- followUpTime: ms(process.env.FOLLOW_UP_TIME || DEFAULT_FOLLOWUP_TIME),
- disqualifyTime: ms(process.env.DISQUALIFY_TIME || DEFAULT_DISQUALIFY_TIME),
+ timeRangeForMaxIssue: process.env.DEFAULT_TIME_RANGE_FOR_MAX_ISSUE
+ ? Number(process.env.DEFAULT_TIME_RANGE_FOR_MAX_ISSUE)
+ : timeRangeForMaxIssue,
+ timeRangeForMaxIssueEnabled: process.env.DEFAULT_TIME_RANGE_FOR_MAX_ISSUE_ENABLED
+ ? process.env.DEFAULT_TIME_RANGE_FOR_MAX_ISSUE_ENABLED == "true"
+ : timeRangeForMaxIssueEnabled,
+ followUpTime: ms(process.env.FOLLOW_UP_TIME || followUpTime),
+ disqualifyTime: ms(process.env.DISQUALIFY_TIME || disqualifyTime),
},
supabase: {
url: process.env.SUPABASE_URL ?? "",
@@ -58,28 +80,47 @@ export const loadConfig = async (context: Context): Promise => {
},
telegram: {
token: process.env.TELEGRAM_BOT_TOKEN ?? "",
- delay: process.env.TELEGRAM_BOT_DELAY ? Number(process.env.TELEGRAM_BOT_DELAY) : DEFAULT_BOT_DELAY,
+ delay: process.env.TELEGRAM_BOT_DELAY ? Number(process.env.TELEGRAM_BOT_DELAY) : botDelay,
+ },
+ logNotification: {
+ url: process.env.LOG_WEBHOOK_BOT_URL || "",
+ secret: process.env.LOG_WEBHOOK_SECRET || "",
+ groupId: Number(process.env.LOG_WEBHOOK_GROUP_ID) || 0,
+ topicId: Number(process.env.LOG_WEBHOOK_TOPIC_ID) || 0,
+ enabled: true,
},
mode: {
- autoPayMode: autoPayMode,
- analyticsMode: analyticsMode,
+ paymentPermitMaxPrice: paymentPermitMaxPrice,
+ disableAnalytics: disableAnalytics,
incentiveMode: incentiveMode,
+ assistivePricing: assistivePricing,
},
+ command: commandSettings,
assign: {
bountyHunterMax: bountyHunterMax,
+ staleBountyTime: ms(staleBountyTime),
},
sodium: {
privateKey: process.env.X25519_PRIVATE_KEY ?? "",
publicKey: publicKey ?? "",
},
+ wallet: {
+ registerWalletWithVerification: registerWalletWithVerification,
+ },
+ ask: {
+ apiKey: openAIKey,
+ tokenLimit: openAITokenLimit || 0,
+ },
+ accessControl: enableAccessControl,
+ newContributorGreeting: newContributorGreeting,
};
- if (botConfig.log.ingestionKey == "") {
- throw new Error("LogDNA ingestion key missing");
+ if (botConfig.payout.privateKey == "") {
+ botConfig.mode.paymentPermitMaxPrice = 0;
}
- if (botConfig.payout.privateKey == "") {
- botConfig.mode.autoPayMode = false;
+ if (botConfig.logNotification.secret == "" || botConfig.logNotification.groupId == 0 || botConfig.logNotification.url == "") {
+ botConfig.logNotification.enabled = false;
}
const validate = ajv.compile(BotConfigSchema);
diff --git a/src/bindings/event.ts b/src/bindings/event.ts
index f85cc680d..e3895541a 100644
--- a/src/bindings/event.ts
+++ b/src/bindings/event.ts
@@ -1,12 +1,13 @@
import { Context } from "probot";
-import { createLogger } from "@logdna/logger";
import { createAdapters } from "../adapters";
import { processors, wildcardProcessors } from "../handlers/processors";
import { shouldSkip } from "../helpers";
-import { BotConfig, GithubEvent, Payload, PayloadSchema } from "../types";
+import { BotConfig, GithubEvent, Payload, PayloadSchema, LogLevel } from "../types";
import { Adapters } from "../types/adapters";
import { ajv } from "../utils";
import { loadConfig } from "./config";
+import { GitHubLogger } from "../adapters/supabase";
+import { validateConfigChange } from "../handlers/push";
let botContext: Context = {} as Context;
export const getBotContext = () => botContext;
@@ -23,6 +24,7 @@ export type Logger = {
warn: (msg: string | object, options?: JSON) => void;
error: (msg: string | object, options?: JSON) => void;
};
+
let logger: Logger;
export const getLogger = (): Logger => logger;
@@ -32,28 +34,54 @@ export const bindEvents = async (context: Context): Promise => {
const { id, name } = context;
botContext = context;
const payload = context.payload as Payload;
+ const allowedEvents = Object.values(GithubEvent) as string[];
+ const eventName = payload.action ? `${name}.${payload.action}` : name; // some events wont have actions as this grows
+
+ let botConfigError;
+ try {
+ botConfig = await loadConfig(context);
+ } catch (err) {
+ botConfigError = err;
+ }
- botConfig = await loadConfig(context);
+ adapters = createAdapters(botConfig);
const options = {
app: "UbiquiBot",
- level: botConfig.log.level,
+ // level: botConfig.log.level,
};
- logger = createLogger(botConfig.log.ingestionKey, options) as Logger;
+
+ logger = new GitHubLogger(
+ options.app,
+ botConfig?.log?.logEnvironment ?? "development",
+ botConfig?.log?.level ?? LogLevel.DEBUG,
+ botConfig?.log?.retryLimit ?? 0,
+ botConfig.logNotification
+ ); // contributors will see logs in console while on development env
if (!logger) {
return;
}
+ if (botConfigError) {
+ logger.error(botConfigError.toString());
+ if (eventName === GithubEvent.PUSH_EVENT) {
+ await validateConfigChange();
+ }
+ return;
+ }
+
+ // Create adapters for telegram, supabase, twitter, discord, etc
+ logger.info("Creating adapters for supabase, telegram, twitter, etc...");
+
logger.info(
`Config loaded! config: ${JSON.stringify({
price: botConfig.price,
unassign: botConfig.unassign,
mode: botConfig.mode,
log: botConfig.log,
+ wallet: botConfig.wallet,
})}`
);
- const allowedEvents = Object.values(GithubEvent) as string[];
- const eventName = payload.action ? `${name}.${payload.action}` : name; // some events wont have actions as this grows
logger.info(`Started binding events... id: ${id}, name: ${eventName}, allowedEvents: ${allowedEvents}`);
@@ -63,10 +91,6 @@ export const bindEvents = async (context: Context): Promise => {
return;
}
- // Create adapters for telegram, supabase, twitter, discord, etc
- logger.info("Creating adapters for supabase, telegram, twitter, etc...");
- adapters = createAdapters(botConfig);
-
// Skip validation for installation event and push
if (!NO_VALIDATION.includes(eventName)) {
// Validate payload
@@ -111,8 +135,8 @@ export const bindEvents = async (context: Context): Promise => {
await postAction();
}
- // Skip wildcard handlers for installation event
- if (eventName !== GithubEvent.INSTALLATION_ADDED_EVENT) {
+ // Skip wildcard handlers for installation event and push event
+ if (eventName !== GithubEvent.INSTALLATION_ADDED_EVENT && eventName !== GithubEvent.PUSH_EVENT) {
// Run wildcard handlers
logger.info(`Running wildcard handlers: ${wildcardProcessors.map((fn) => fn.name)}`);
for (const wildcardProcessor of wildcardProcessors) {
diff --git a/src/configs/index.ts b/src/configs/index.ts
index 8fe004659..6d4bef9de 100644
--- a/src/configs/index.ts
+++ b/src/configs/index.ts
@@ -1,4 +1,3 @@
-export * from "./price";
-export * from "./shared";
export * from "./strings";
export * from "./abis";
+export * from "./ubiquibot-config-default";
diff --git a/src/configs/price.ts b/src/configs/price.ts
deleted file mode 100644
index d67c3fb5a..000000000
--- a/src/configs/price.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { PriceConfig } from "../types";
-import { MarkdownItem } from "../types/markdown";
-
-export const DefaultPriceConfig: PriceConfig = {
- baseMultiplier: 1000,
- issueCreatorMultiplier: 2000,
- timeLabels: [
- {
- name: "Time: <1 Hour",
- weight: 0.125,
- value: 3600,
- },
- {
- name: "Time: <1 Day",
- weight: 1,
- value: 3600 * 24,
- },
- {
- name: "Time: <1 Week",
- weight: 2,
- value: 3600 * 24 * 7,
- },
- {
- name: "Time: <2 Weeks",
- weight: 3,
- value: 3600 * 24 * 14,
- },
- {
- name: "Time: <1 Month",
- weight: 4,
- value: 3600 * 24 * 30,
- },
- ],
- priorityLabels: [
- {
- name: "Priority: 0 (Normal)",
- weight: 1,
- },
- {
- name: "Priority: 1 (Medium)",
- weight: 2,
- },
- {
- name: "Priority: 2 (High)",
- weight: 3,
- },
- {
- name: "Priority: 3 (Urgent)",
- weight: 4,
- },
- {
- name: "Priority: 4 (Emergency)",
- weight: 5,
- },
- ],
- commentElementPricing: {
- [MarkdownItem.Text]: 0.1,
- [MarkdownItem.Link]: 0.5,
- [MarkdownItem.List]: 0.5,
- [MarkdownItem.Code]: 5,
- [MarkdownItem.Image]: 5,
- },
-};
diff --git a/src/configs/shared.ts b/src/configs/shared.ts
deleted file mode 100644
index aa60505ac..000000000
--- a/src/configs/shared.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { generateHelpMenu } from "../handlers";
-
-export const COLORS = {
- default: "ededed",
- price: "1f883d",
-};
-export const DEFAULT_BOT_DELAY = 100; // 100ms
-export const DEFAULT_TIME_RANGE_FOR_MAX_ISSUE = 24;
-export const DEFAULT_TIME_RANGE_FOR_MAX_ISSUE_ENABLED = true;
-export const ASSIGN_COMMAND_ENABLED = true;
-/**
- * ms('2 days') // 172800000
- * ms('1d') // 86400000
- * ms('10h') // 36000000
- * ms('2.5 hrs') // 9000000
- * ms('2h') // 7200000
- * ms('1m') // 60000
- * ms('5s') // 5000
- * ms('1y') // 31557600000
- * ms('100') // 100
- * ms('-3 days') // -259200000
- * ms('-1h') // -3600000
- * ms('-200') // -200
- */
-export const DEFAULT_FOLLOWUP_TIME = "4 days"; // 4 days
-export const DEFAULT_DISQUALIFY_TIME = "7 days"; // 7 days
-
-export const DEFAULT_NETWORK_ID = 1; // ethereum
-export const DEFAULT_RPC_ENDPOINT = "https://rpc-bot.ubq.fi/v1/mainnet";
-export const DEFAULT_PERMIT_BASE_URL = "https://pay.ubq.fi";
-export const COMMAND_INSTRUCTIONS = generateHelpMenu();
diff --git a/src/configs/strings.ts b/src/configs/strings.ts
index 9bc705a20..3f3128920 100644
--- a/src/configs/strings.ts
+++ b/src/configs/strings.ts
@@ -2,4 +2,8 @@ export const GLOBAL_STRINGS = {
unassignComment: "Releasing the bounty back to dev pool because the allocated duration already ended!",
askUpdate: "Do you have any updates",
assignCommandDisabledComment: "The `/assign` command is disabled for this repository.",
+ skipPriceLabelGenerationComment: "Pricing is disabled on parent issues.",
+ ignoreStartCommandForParentIssueComment:
+ "Please select a child issue from the specification checklist to work on. The `/start` command is disabled on parent issues.",
+ autopayComment: "Automatic payment for this issue is enabled:",
};
diff --git a/src/configs/ubiquibot-config-default.ts b/src/configs/ubiquibot-config-default.ts
new file mode 100644
index 000000000..9598ef782
--- /dev/null
+++ b/src/configs/ubiquibot-config-default.ts
@@ -0,0 +1,115 @@
+import { MergedConfig } from "../types";
+
+export const DefaultConfig: MergedConfig = {
+ evmNetworkId: 100,
+ priceMultiplier: 1,
+ issueCreatorMultiplier: 2,
+ paymentPermitMaxPrice: 9007199254740991,
+ maxConcurrentAssigns: 9007199254740991,
+ assistivePricing: false,
+ disableAnalytics: false,
+ commentIncentives: false,
+ registerWalletWithVerification: false,
+ promotionComment:
+ "\n
If you enjoy the DevPool experience, please follow Ubiquity on GitHub and star this repo to show your support. It helps a lot!
",
+ defaultLabels: [],
+ timeLabels: [
+ {
+ name: "Time: <1 Hour",
+ },
+ {
+ name: "Time: <1 Day",
+ },
+ {
+ name: "Time: <1 Week",
+ },
+ {
+ name: "Time: <2 Weeks",
+ },
+ {
+ name: "Time: <1 Month",
+ },
+ ],
+ priorityLabels: [
+ {
+ name: "Priority: 1 (Normal)",
+ },
+ {
+ name: "Priority: 2 (Medium)",
+ },
+ {
+ name: "Priority: 3 (High)",
+ },
+ {
+ name: "Priority: 4 (Urgent)",
+ },
+ {
+ name: "Priority: 5 (Emergency)",
+ },
+ ],
+ commandSettings: [
+ {
+ name: "start",
+ enabled: false,
+ },
+ {
+ name: "stop",
+ enabled: false,
+ },
+ {
+ name: "wallet",
+ enabled: false,
+ },
+ {
+ name: "payout",
+ enabled: false,
+ },
+ {
+ name: "multiplier",
+ enabled: false,
+ },
+ {
+ name: "query",
+ enabled: false,
+ },
+ {
+ name: "ask",
+ enabled: false,
+ },
+ {
+ name: "allow",
+ enabled: false,
+ },
+ {
+ name: "autopay",
+ enabled: false,
+ },
+ ],
+ incentives: {
+ comment: {
+ elements: {},
+ totals: {
+ word: 0,
+ },
+ },
+ },
+ enableAccessControl: {
+ label: false,
+ organization: true,
+ },
+ staleBountyTime: "0d",
+ timeRangeForMaxIssue: 24, //24
+ timeRangeForMaxIssueEnabled: false,
+ permitBaseUrl: "https://pay.ubq.fi",
+ botDelay: 100, // 100ms
+ followUpTime: "4 days",
+ disqualifyTime: "7 days",
+ newContributorGreeting: {
+ enabled: true,
+ header:
+ "Thank you for contributing to UbiquiBot! Please be sure to set your wallet address before completing your first bounty so that the automatic payout upon task completion will work for you.",
+ helpMenu: true,
+ footer:
+ "###### Also please star this repository and [@ubiquity/devpool-directory](https://github.com/ubiquity/devpool-directory/) to show your support. It helps a lot!",
+ },
+};
diff --git a/src/handlers/access/labels-access.ts b/src/handlers/access/labels-access.ts
index 184a10c0a..228aa5a38 100644
--- a/src/handlers/access/labels-access.ts
+++ b/src/handlers/access/labels-access.ts
@@ -1,9 +1,12 @@
import { getAccessLevel } from "../../adapters/supabase";
-import { getBotContext, getLogger } from "../../bindings";
+import { getBotConfig, getBotContext, getLogger } from "../../bindings";
import { addCommentToIssue, getUserPermission, removeLabel, addLabelToIssue } from "../../helpers";
import { Payload } from "../../types";
export const handleLabelsAccess = async () => {
+ const { accessControl } = getBotConfig();
+ if (!accessControl.label) return true;
+
const context = getBotContext();
const logger = getLogger();
const payload = context.payload as Payload;
diff --git a/src/handlers/assign/action.ts b/src/handlers/assign/action.ts
index 951ad3314..d02d1589c 100644
--- a/src/handlers/assign/action.ts
+++ b/src/handlers/assign/action.ts
@@ -1,5 +1,6 @@
import { getBotConfig, getBotContext, getLogger } from "../../bindings";
-import { addCommentToIssue, closePullRequest, getOpenedPullRequestsForAnIssue } from "../../helpers";
+import { addCommentToIssue, closePullRequest, calculateWeight, calculateDuration } from "../../helpers";
+import { gitLinkedPrParser } from "../../helpers/parser";
import { Payload, LabelItem } from "../../types";
import { deadLinePrefix } from "../shared";
@@ -49,21 +50,21 @@ export const commentWithAssignMessage = async (): Promise => {
return;
}
- const sorted = timeLabelsAssigned.sort((a, b) => a.weight - b.weight);
+ const sorted = timeLabelsAssigned.sort((a, b) => calculateWeight(a) - calculateWeight(b));
const targetTimeLabel = sorted[0];
- const duration = targetTimeLabel.value;
+ const duration = calculateDuration(targetTimeLabel);
if (!duration) {
logger.debug(`Missing configure for timelabel: ${targetTimeLabel.name}`);
return;
}
- const curDate = new Date();
- const curDateInMillisecs = curDate.getTime();
- const endDate = new Date(curDateInMillisecs + duration * 1000);
- const commit_msg = `${flattened_assignees} ${deadLinePrefix} ${endDate.toUTCString()}`;
- logger.debug(`Creating an issue comment, commit_msg: ${commit_msg}`);
+ const currentDate = new Date();
+ const currentDateInMilliseconds = currentDate.getTime();
+ const endDate = new Date(currentDateInMilliseconds + duration * 1000);
+ const commitMessage = `${flattened_assignees} ${deadLinePrefix} ${endDate.toUTCString().replace("GMT", "UTC")}`;
+ logger.debug(`Creating an issue comment, commit_msg: ${commitMessage}`);
- await addCommentToIssue(commit_msg, payload.issue?.number);
+ await addCommentToIssue(commitMessage, payload.issue?.number);
};
export const closePullRequestForAnIssue = async (): Promise => {
@@ -72,13 +73,19 @@ export const closePullRequestForAnIssue = async (): Promise => {
const payload = context.payload as Payload;
if (!payload.issue?.number) return;
- const prs = await getOpenedPullRequestsForAnIssue(payload.issue.number, "");
+ const prs = await gitLinkedPrParser({
+ owner: payload.repository.owner.login,
+ repo: payload.repository.name,
+ issue_number: payload.issue.number,
+ });
+
if (!prs.length) return;
+
logger.info(`Opened prs for this issue: ${JSON.stringify(prs)}`);
let comment = `These linked pull requests are closed: `;
for (let i = 0; i < prs.length; i++) {
- await closePullRequest(prs[i].number);
- comment += ` #${prs[i].number} `;
+ await closePullRequest(prs[i].prNumber);
+ comment += ` #${prs[i].prNumber} `;
}
await addCommentToIssue(comment, payload.issue.number);
};
diff --git a/src/handlers/assign/auto.ts b/src/handlers/assign/auto.ts
index a065327f4..380b59983 100644
--- a/src/handlers/assign/auto.ts
+++ b/src/handlers/assign/auto.ts
@@ -1,5 +1,5 @@
import { getBotContext, getLogger } from "../../bindings";
-import { addAssignees, getIssueByNumber, getPullByNumber, getPullRequests } from "../../helpers";
+import { addAssignees, getAllPullRequests, getIssueByNumber, getPullByNumber } from "../../helpers";
import { gitLinkedIssueParser } from "../../helpers/parser";
import { Payload } from "../../types";
@@ -7,7 +7,7 @@ import { Payload } from "../../types";
export const checkPullRequests = async () => {
const context = getBotContext();
const logger = getLogger();
- const pulls = await getPullRequests(context);
+ const pulls = await getAllPullRequests(context);
if (pulls.length === 0) {
logger.debug(`No pull requests found at this time`);
@@ -18,14 +18,14 @@ export const checkPullRequests = async () => {
// Loop through the pull requests and assign them to their respective issues if needed
for (const pull of pulls) {
- const pullRequestLinked = await gitLinkedIssueParser({
+ const linkedIssue = await gitLinkedIssueParser({
owner: payload.repository.owner.login,
repo: payload.repository.name,
- issue_number: pull.number,
+ pull_number: pull.number,
});
// if pullRequestLinked is empty, continue
- if (pullRequestLinked == "" || !pull.user) {
+ if (linkedIssue == "" || !pull.user || !linkedIssue) {
continue;
}
@@ -37,7 +37,7 @@ export const checkPullRequests = async () => {
continue;
}
- const linkedIssueNumber = pullRequestLinked.substring(pullRequestLinked.lastIndexOf("/") + 1);
+ const linkedIssueNumber = linkedIssue.substring(linkedIssue.lastIndexOf("/") + 1);
// Check if the pull request opener is assigned to the issue
const opener = pull.user.login;
diff --git a/src/handlers/comment/action.ts b/src/handlers/comment/action.ts
index faa7a24ae..60ea5bbae 100644
--- a/src/handlers/comment/action.ts
+++ b/src/handlers/comment/action.ts
@@ -1,10 +1,13 @@
-import { getBotContext, getLogger } from "../../bindings";
+import { getBotConfig, getBotContext, getLogger } from "../../bindings";
import { Payload } from "../../types";
+import { ErrorDiff } from "../../utils/helpers";
+import { IssueCommentCommands } from "./commands";
import { commentParser, userCommands } from "./handlers";
import { verifyFirstCheck } from "./handlers/first";
export const handleComment = async (): Promise => {
const context = getBotContext();
+ const config = getBotConfig();
const logger = getLogger();
const payload = context.payload as Payload;
@@ -16,34 +19,43 @@ export const handleComment = async (): Promise => {
}
const body = comment.body;
- const commands = commentParser(body);
+ const commentedCommands = commentParser(body);
- if (commands.length === 0) {
+ if (commentedCommands.length === 0) {
await verifyFirstCheck();
return;
}
- for (const command of commands) {
- const userCommand = userCommands.find((i) => i.id == command);
+ const allCommands = userCommands();
+ for (const command of commentedCommands) {
+ const userCommand = allCommands.find((i) => i.id == command);
if (userCommand) {
- const { handler, callback, successComment, failureComment } = userCommand;
+ const { id, handler, callback, successComment, failureComment } = userCommand;
logger.info(`Running a comment handler: ${handler.name}`);
const { payload: _payload } = getBotContext();
const issue = (_payload as Payload).issue;
if (!issue) continue;
+ const feature = config.command.find((e) => e.name === id.split("/")[1]);
+
+ if (!feature?.enabled && id !== IssueCommentCommands.HELP) {
+ logger.info(`Skipping '${id}' because it is disabled on this repo.`);
+ await callback(issue.number, `Skipping \`${id}\` because it is disabled on this repo.`, payload.action, payload.comment);
+ continue;
+ }
+
try {
const response = await handler(body);
const callbackComment = response ?? successComment ?? "";
- if (callbackComment) await callback(issue.number, callbackComment);
+ if (callbackComment) await callback(issue.number, callbackComment, payload.action, payload.comment);
} catch (err: unknown) {
// Use failureComment for failed command if it is available
if (failureComment) {
- await callback(issue.number, failureComment);
+ await callback(issue.number, failureComment, payload.action, payload.comment);
}
- await callback(issue.number, `Error: ${err}`);
+ await callback(issue.number, ErrorDiff(err), payload.action, payload.comment);
}
} else {
logger.info(`Skipping for a command: ${command}`);
diff --git a/src/handlers/comment/commands.ts b/src/handlers/comment/commands.ts
index 98c52990f..9bce9fc6e 100644
--- a/src/handlers/comment/commands.ts
+++ b/src/handlers/comment/commands.ts
@@ -1,12 +1,15 @@
export enum IssueCommentCommands {
HELP = "/help", // list available commands
- ASSIGN = "/assign", // assign the hunter to the issue automatically
- UNASSIGN = "/unassign", // unassign to default
+ START = "/start", // assign the hunter to the issue automatically
+ STOP = "/stop", // unassign to default
WALLET = "/wallet", // register wallet address
PAYOUT = "/payout", // request permit payout
MULTIPLIER = "/multiplier", // set bounty multiplier (for treasury)
-
+ QUERY = "/query",
+ ASK = "/ask", // ask GPT a question
// Access Controls
ALLOW = "/allow",
+ AUTOPAY = "/autopay",
+ AUTHORIZE = "/authorize",
}
diff --git a/src/handlers/comment/handlers/set-access.ts b/src/handlers/comment/handlers/allow.ts
similarity index 72%
rename from src/handlers/comment/handlers/set-access.ts
rename to src/handlers/comment/handlers/allow.ts
index 1c855690c..aa867985c 100644
--- a/src/handlers/comment/handlers/set-access.ts
+++ b/src/handlers/comment/handlers/allow.ts
@@ -20,13 +20,20 @@ export const setAccess = async (body: string) => {
return;
}
- const regex = /^\/allow set-(\S+)\s@(\w+)\s(true|false)$/;
-
+ const regex = /\/allow\s+(\S+)\s+(\S+)\s+(\S+)/;
const matches = body.match(regex);
if (matches) {
- const [, accessType, username, bool] = matches;
-
+ let accessType, username, bool;
+ matches.slice(1).forEach((part) => {
+ if (part.startsWith("@")) username = part.slice(1);
+ else if (part.startsWith("set-")) accessType = part.slice(4);
+ else if (part === "true" || part === "false") bool = part;
+ });
+ if (!accessType || !username || !bool) {
+ logger.error("Invalid body for allow command");
+ return `Invalid syntax for allow \n usage: '/allow set-(access type) @user true|false' \n ex-1 /allow set-multiplier @user false`;
+ }
// Check if access control demand is valid
if (!validAccessString.includes(accessType)) {
logger.info(`Access control setting for ${accessType} does not exist.`);
@@ -50,6 +57,6 @@ export const setAccess = async (body: string) => {
return `Updated access for @${username} successfully!\t Access: **${accessType}** for "${repo.full_name}"`;
} else {
logger.error("Invalid body for allow command");
- return `Invalid body for allow command`;
+ return `Invalid syntax for allow \n usage: '/allow set-(access type) @user true|false' \n ex-1 /allow set-multiplier @user false`;
}
};
diff --git a/src/handlers/comment/handlers/ask.ts b/src/handlers/comment/handlers/ask.ts
new file mode 100644
index 000000000..63777d4ae
--- /dev/null
+++ b/src/handlers/comment/handlers/ask.ts
@@ -0,0 +1,123 @@
+import { getBotContext, getLogger } from "../../../bindings";
+import { Payload, StreamlinedComment, UserType } from "../../../types";
+import { getAllIssueComments, getAllLinkedIssuesAndPullsInBody } from "../../../helpers";
+import { CreateChatCompletionRequestMessage } from "openai/resources/chat";
+import { askGPT, decideContextGPT, sysMsg } from "../../../helpers/gpt";
+import { ErrorDiff } from "../../../utils/helpers";
+
+/**
+ * @param body The question to ask
+ */
+export const ask = async (body: string) => {
+ const context = getBotContext();
+ const logger = getLogger();
+
+ const payload = context.payload as Payload;
+ const sender = payload.sender.login;
+ const issue = payload.issue;
+
+ if (!body) {
+ return `Please ask a question`;
+ }
+
+ if (!issue) {
+ return `This command can only be used on issues`;
+ }
+
+ const chatHistory: CreateChatCompletionRequestMessage[] = [];
+ const streamlined: StreamlinedComment[] = [];
+ let linkedPRStreamlined: StreamlinedComment[] = [];
+ let linkedIssueStreamlined: StreamlinedComment[] = [];
+
+ const regex = /^\/ask\s(.+)$/;
+ const matches = body.match(regex);
+
+ if (matches) {
+ const [, body] = matches;
+
+ // standard comments
+ const comments = await getAllIssueComments(issue.number);
+ // raw so we can grab the tag
+ const commentsRaw = await getAllIssueComments(issue.number, "raw");
+
+ if (!comments) {
+ logger.info(`Error getting issue comments`);
+ return ErrorDiff(`Error getting issue comments`);
+ }
+
+ // add the first comment of the issue/pull request
+ streamlined.push({
+ login: issue.user.login,
+ body: issue.body,
+ });
+
+ // add the rest
+ comments.forEach(async (comment, i) => {
+ if (comment.user.type == UserType.User || commentsRaw[i].body.includes("")) {
+ streamlined.push({
+ login: comment.user.login,
+ body: comment.body,
+ });
+ }
+ });
+
+ // returns the conversational context from all linked issues and prs
+ const links = await getAllLinkedIssuesAndPullsInBody(issue.number);
+
+ if (typeof links === "string") {
+ logger.info(`Error getting linked issues or prs: ${links}`);
+ } else {
+ linkedIssueStreamlined = links.linkedIssues;
+ linkedPRStreamlined = links.linkedPrs;
+ }
+
+ // let chatgpt deduce what is the most relevant context
+ const gptDecidedContext = await decideContextGPT(chatHistory, streamlined, linkedPRStreamlined, linkedIssueStreamlined);
+
+ if (linkedIssueStreamlined.length == 0 && linkedPRStreamlined.length == 0) {
+ // No external context to add
+ chatHistory.push(
+ {
+ role: "system",
+ content: sysMsg,
+ name: "UbiquityAI",
+ } as CreateChatCompletionRequestMessage,
+ {
+ role: "user",
+ content: body,
+ name: sender,
+ } as CreateChatCompletionRequestMessage
+ );
+ } else {
+ chatHistory.push(
+ {
+ role: "system",
+ content: sysMsg, // provide the answer template
+ name: "UbiquityAI",
+ } as CreateChatCompletionRequestMessage,
+ {
+ role: "system",
+ content: "Original Context: " + JSON.stringify(gptDecidedContext), // provide the context
+ name: "system",
+ } as CreateChatCompletionRequestMessage,
+ {
+ role: "user",
+ content: "Question: " + JSON.stringify(body), // provide the question
+ name: "user",
+ } as CreateChatCompletionRequestMessage
+ );
+ }
+
+ const gptResponse = await askGPT(body, chatHistory);
+
+ if (typeof gptResponse === "string") {
+ return gptResponse;
+ } else if (gptResponse.answer) {
+ return gptResponse.answer;
+ } else {
+ return ErrorDiff(`Error getting response from GPT`);
+ }
+ } else {
+ return "Invalid syntax for ask \n usage: '/ask What is pi?";
+ }
+};
diff --git a/src/handlers/comment/handlers/assign.ts b/src/handlers/comment/handlers/assign.ts
index 604429cac..d4ab030ef 100644
--- a/src/handlers/comment/handlers/assign.ts
+++ b/src/handlers/comment/handlers/assign.ts
@@ -1,11 +1,12 @@
-import { addAssignees, getAssignedIssues, getCommentsOfIssue, getAvailableOpenedPullRequests, addCommentToIssue } from "../../../helpers";
+import { addAssignees, getAssignedIssues, getAvailableOpenedPullRequests, getAllIssueComments, calculateWeight, calculateDuration } from "../../../helpers";
import { getBotConfig, getBotContext, getLogger } from "../../../bindings";
-import { Payload, LabelItem, Comment, IssueType } from "../../../types";
+import { Payload, LabelItem, Comment, IssueType, Issue } from "../../../types";
import { deadLinePrefix } from "../../shared";
-import { getWalletAddress, getWalletMultiplier, getMultiplierReason } from "../../../adapters/supabase";
+import { getWalletAddress, getWalletMultiplier } from "../../../adapters/supabase";
import { tableComment } from "./table";
import { bountyInfo } from "../../wildcard";
-import { ASSIGN_COMMAND_ENABLED, GLOBAL_STRINGS } from "../../../configs";
+import { GLOBAL_STRINGS } from "../../../configs";
+import { isParentIssue } from "../../pricing";
export const assign = async (body: string) => {
const { payload: _payload } = getBotContext();
@@ -13,17 +14,29 @@ export const assign = async (body: string) => {
const config = getBotConfig();
const payload = _payload as Payload;
- logger.info(`Received '/assign' command from user: ${payload.sender.login}, body: ${body}`);
+ const { repository, organization } = payload;
+
+ const id = organization?.id || repository?.id; // repository?.id as fallback
+
+ const staleBounty = config.assign.staleBountyTime;
+ const startEnabled = config.command.find((command) => command.name === "start");
+
+ logger.info(`Received '/start' command from user: ${payload.sender.login}, body: ${body}`);
const issue = (_payload as Payload).issue;
+
if (!issue) {
- logger.info(`Skipping '/assign' because of no issue instance`);
- return "Skipping '/assign' because of no issue instance";
+ logger.info(`Skipping '/start' because of no issue instance`);
+ return "Skipping '/start' because of no issue instance";
}
- if (!ASSIGN_COMMAND_ENABLED) {
- logger.info(`Ignore '/assign' command from user: ASSIGN_COMMAND_ENABLED config is set false`);
- await addCommentToIssue(GLOBAL_STRINGS.assignCommandDisabledComment, issue.number);
- return;
+ if (!startEnabled?.enabled) {
+ logger.info(`Ignore '/start' command from user: ASSIGN_COMMAND_ENABLED config is set false`);
+ return GLOBAL_STRINGS.assignCommandDisabledComment;
+ }
+
+ if (issue.body && isParentIssue(issue.body)) {
+ logger.info(`Ignore '/start' command from user: identified as parent issue`);
+ return GLOBAL_STRINGS.ignoreStartCommandForParentIssueComment;
}
const openedPullRequests = await getAvailableOpenedPullRequests(payload.sender.login);
@@ -38,22 +51,22 @@ export const assign = async (body: string) => {
}
if (issue.state == IssueType.CLOSED) {
- logger.info("Skipping '/assign', reason: closed ");
- return "Skipping `/assign` since the issue is closed";
+ logger.info("Skipping '/start', reason: closed ");
+ return "Skipping `/start` since the issue is closed";
}
const _assignees = payload.issue?.assignees;
const assignees = _assignees ?? [];
if (assignees.length !== 0) {
- logger.info(`Skipping '/assign', reason: already assigned. assignees: ${assignees.length > 0 ? assignees.map((i) => i.login).join() : "NoAssignee"}`);
- return "Skipping `/assign` since the issue is already assigned";
+ logger.info(`Skipping '/start', reason: already assigned. assignees: ${assignees.length > 0 ? assignees.map((i) => i.login).join() : "NoAssignee"}`);
+ return "Skipping `/start` since the issue is already assigned";
}
// get the time label from the `labels`
const labels = payload.issue?.labels;
if (!labels) {
logger.info(`No labels to calculate timeline`);
- return "Skipping `/assign` since no issue labels are set to calculate the timeline";
+ return "Skipping `/start` since no issue labels are set to calculate the timeline";
}
const timeLabelsDefined = config.price.timeLabels;
const timeLabelsAssigned: LabelItem[] = [];
@@ -69,30 +82,27 @@ export const assign = async (body: string) => {
if (timeLabelsAssigned.length == 0) {
logger.info(`No time labels to calculate timeline`);
- return "Skipping `/assign` since no time labels are set to calculate the timeline";
+ return "Skipping `/start` since no time labels are set to calculate the timeline";
}
- const sorted = timeLabelsAssigned.sort((a, b) => a.weight - b.weight);
+ const sorted = timeLabelsAssigned.sort((a, b) => calculateWeight(a) - calculateWeight(b));
const targetTimeLabel = sorted[0];
- const duration = targetTimeLabel.value;
+ const duration = calculateDuration(targetTimeLabel);
if (!duration) {
logger.info(`Missing configure for time label: ${targetTimeLabel.name}`);
- return "Skipping `/assign` since configuration is missing for the following labels";
+ return "Skipping `/start` since configuration is missing for the following labels";
}
const startTime = new Date().getTime();
const endTime = new Date(startTime + duration * 1000);
const comment = {
- deadline: endTime.toUTCString(),
- wallet: (await getWalletAddress(payload.sender.login)) || "Please set your wallet address to use `/wallet 0x4FDE...BA18`",
- multiplier: "1.00",
- reason: await getMultiplierReason(payload.sender.login),
- bounty: `Permit generation skipped since price label is not set`,
+ deadline: endTime.toUTCString().replace("GMT", "UTC"),
+ wallet: (await getWalletAddress(payload.sender.login)) || "Please set your wallet address to use `/wallet 0x0000...0000`",
commit: `@${payload.sender.login} ${deadLinePrefix} ${endTime.toUTCString()}`,
tips: `
Tips:
-
Use /wallet 0x4FDE...BA18 if you want to update your registered payment wallet address @user.
+
Use /wallet 0x0000...0000 if you want to update your registered payment wallet address @${payload.sender.login}.
Be sure to open a draft pull request as soon as possible to communicate updates on your progress.
Be sure to provide timely updates to us when requested, or you will be automatically unassigned from the bounty.
`,
@@ -103,21 +113,51 @@ export const assign = async (body: string) => {
await addAssignees(issue.number, [payload.sender.login]);
}
+ let days: number | undefined;
+ let staleToDays: number | undefined;
+ let isBountyStale = false;
+
+ if (staleBounty !== 0) {
+ days = Math.floor((new Date().getTime() - new Date(issue.created_at).getTime()) / (1000 * 60 * 60 * 24));
+ staleToDays = Math.floor(staleBounty / (1000 * 60 * 60 * 24));
+ isBountyStale = days >= staleToDays;
+ }
+
// double check whether the assign message has been already posted or not
logger.info(`Creating an issue comment: ${comment.commit}`);
- const issueComments = await getCommentsOfIssue(issue.number);
+ const issueComments = await getAllIssueComments(issue.number);
const comments = issueComments.sort((a: Comment, b: Comment) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
const latestComment = comments.length > 0 ? comments[0].body : undefined;
if (latestComment && comment.commit != latestComment) {
- const multiplier = await getWalletMultiplier(payload.sender.login);
- if (multiplier) {
- comment.multiplier = multiplier.toFixed(2);
+ const { multiplier, reason, bounty } = await getMultiplierInfoToDisplay(payload.sender.login, id?.toString(), issue);
+ return tableComment({ ...comment, multiplier, reason, bounty, isBountyStale, days }) + comment.tips;
+ }
+ return;
+};
+
+const getMultiplierInfoToDisplay = async (senderLogin: string, org_id: string, issue: Issue) => {
+ const { reason, value } = await getWalletMultiplier(senderLogin, org_id);
+
+ const multiplier = value?.toFixed(2) || "1.00";
+
+ let _multiplierToDisplay, _reasonToDisplay, _bountyToDisplay;
+
+ if (value == 1) {
+ if (reason) {
+ _multiplierToDisplay = multiplier;
+ _reasonToDisplay = reason;
+ } else {
+ // default mode: normal bounty hunter with default multiplier 1 and no reason
+ // nothing to show about multiplier
}
+ } else {
+ _multiplierToDisplay = multiplier;
+ _reasonToDisplay = reason;
+ _bountyToDisplay = `Permit generation disabled because price label is not set.`;
const issueDetailed = bountyInfo(issue);
if (issueDetailed.priceLabel) {
- comment.bounty = (+issueDetailed.priceLabel.substring(7, issueDetailed.priceLabel.length - 4) * multiplier).toString() + " USD";
+ _bountyToDisplay = (+issueDetailed.priceLabel.substring(7, issueDetailed.priceLabel.length - 4) * value).toString() + " USD";
}
- return tableComment(comment) + comment.tips;
}
- return;
+ return { multiplier: _multiplierToDisplay, reason: _reasonToDisplay, bounty: _bountyToDisplay };
};
diff --git a/src/handlers/comment/handlers/authorize.ts b/src/handlers/comment/handlers/authorize.ts
new file mode 100644
index 000000000..03918cded
--- /dev/null
+++ b/src/handlers/comment/handlers/authorize.ts
@@ -0,0 +1,45 @@
+import { _approveLabelChange, getLabelChanges } from "../../../adapters/supabase";
+import { getBotContext, getLogger } from "../../../bindings";
+import { getUserPermission } from "../../../helpers";
+import { Payload } from "../../../types";
+import { ErrorDiff } from "../../../utils/helpers";
+import { bountyInfo } from "../../wildcard";
+
+export const approveLabelChange = async () => {
+ const context = getBotContext();
+ const logger = getLogger();
+ const payload = context.payload as Payload;
+ const sender = payload.sender.login;
+
+ logger.info(`Received '/authorize' command from user: ${sender}`);
+
+ const { issue, repository } = payload;
+ if (!issue) {
+ logger.info(`Skipping '/authorize' because of no issue instance`);
+ return;
+ }
+
+ // check if sender is admin
+ // passing in context so we don't have to make another request to get the user
+ const permissionLevel = await getUserPermission(sender, context);
+
+ // if sender is not admin, return
+ if (permissionLevel !== "admin" && permissionLevel !== "billing_manager") {
+ logger.info(`User ${sender} is not an admin/billing_manager`);
+ return ErrorDiff(`You are not an admin/billing_manager and do not have the required permissions to access this function.`);
+ }
+
+ const issueDetailed = bountyInfo(issue);
+
+ if (!issueDetailed.priceLabel || !issueDetailed.priorityLabel || !issueDetailed.timelabel) {
+ logger.info(`Skipping... its not a bounty`);
+ return ErrorDiff(`No valid bounty label on this issue`);
+ }
+
+ // check for label altering here
+ const labelChanges = await getLabelChanges(repository.full_name, [issueDetailed.priceLabel, issueDetailed.priorityLabel, issueDetailed.timelabel]);
+
+ await _approveLabelChange(labelChanges.id);
+
+ return `Label change has been approved, permit can now be generated`;
+};
diff --git a/src/handlers/comment/handlers/first.ts b/src/handlers/comment/handlers/first.ts
index 13aa8163a..77e898420 100644
--- a/src/handlers/comment/handlers/first.ts
+++ b/src/handlers/comment/handlers/first.ts
@@ -1,32 +1,48 @@
-import { getBotContext, getLogger } from "../../../bindings";
-import { COMMAND_INSTRUCTIONS } from "../../../configs";
-import { addCommentToIssue } from "../../../helpers";
+import { getBotConfig, getBotContext, getLogger } from "../../../bindings";
+import { upsertCommentToIssue } from "../../../helpers";
import { Payload } from "../../../types";
+import { generateHelpMenu } from "./help";
export const verifyFirstCheck = async (): Promise => {
const context = getBotContext();
const logger = getLogger();
const payload = context.payload as Payload;
+ let msg = "";
if (!payload.issue) return;
-
+ const {
+ newContributorGreeting: { header, helpMenu, footer, enabled },
+ } = getBotConfig();
try {
- const response = await context.octokit.rest.search.issuesAndPullRequests({
+ const response_issue = await context.octokit.rest.search.issuesAndPullRequests({
q: `is:issue repo:${payload.repository.owner.login}/${payload.repository.name} commenter:${payload.sender.login}`,
per_page: 2,
});
- if (response.data.total_count === 1) {
+ const response_pr = await context.octokit.rest.search.issuesAndPullRequests({
+ q: `is:pull-request repo:${payload.repository.owner.login}/${payload.repository.name} commenter:${payload.sender.login}`,
+ per_page: 2,
+ });
+ if (response_issue.data.total_count + response_pr.data.total_count === 1) {
//continue_first_search
+ const data = response_issue.data.total_count > 0 ? response_issue.data : response_pr.data;
const resp = await context.octokit.rest.issues.listComments({
- issue_number: response.data.items[0].number,
+ issue_number: data.items[0].number,
owner: payload.repository.owner.login,
repo: payload.repository.name,
per_page: 100,
});
const isFirstComment = resp.data.filter((item) => item.user?.login === payload.sender.login).length === 1;
- if (isFirstComment) {
+ if (isFirstComment && enabled) {
//first_comment
- const msg = `${COMMAND_INSTRUCTIONS}\n@${payload.sender.login}`;
- await addCommentToIssue(msg, payload.issue.number);
+ if (header) {
+ msg += `${header}\n`;
+ }
+ if (helpMenu) {
+ msg += `${generateHelpMenu()}\n@${payload.sender.login}\n`;
+ }
+ if (footer) {
+ msg += `${footer}`;
+ }
+ await upsertCommentToIssue(payload.issue.number, msg, payload.action, payload.comment);
}
}
} catch (error: unknown) {
diff --git a/src/handlers/comment/handlers/help.ts b/src/handlers/comment/handlers/help.ts
index 815e8fa79..c2f545049 100644
--- a/src/handlers/comment/handlers/help.ts
+++ b/src/handlers/comment/handlers/help.ts
@@ -1,6 +1,5 @@
import { userCommands } from ".";
-import { getBotContext, getLogger } from "../../../bindings";
-import { ASSIGN_COMMAND_ENABLED } from "../../../configs";
+import { getBotConfig, getBotContext, getLogger } from "../../../bindings";
import { IssueType, Payload } from "../../../types";
import { IssueCommentCommands } from "../commands";
@@ -20,7 +19,7 @@ export const listAvailableCommands = async (body: string) => {
}
if (issue.state == IssueType.CLOSED) {
- logger.info("Skipping '/assign', reason: closed ");
+ logger.info("Skipping '/start', reason: closed ");
return;
}
@@ -28,21 +27,23 @@ export const listAvailableCommands = async (body: string) => {
};
export const generateHelpMenu = () => {
+ const config = getBotConfig();
+ const startEnabled = config.command.find((command) => command.name === "start");
let helpMenu = "### Available commands\n```";
-
- userCommands.map((command) => {
+ const commands = userCommands();
+ commands.map((command) => {
// if first command, add a new line
- if (command.id === userCommands[0].id) {
+ if (command.id === commands[0].id) {
helpMenu += `\n`;
- if (!ASSIGN_COMMAND_ENABLED) return;
+ if (!startEnabled) return;
}
helpMenu += `- ${command.id}: ${command.description}`;
// if not last command, add a new line (fixes too much space below)
- if (command.id !== userCommands[userCommands.length - 1].id) {
+ if (command.id !== commands[commands.length - 1].id) {
helpMenu += `\n`;
}
});
- if (!ASSIGN_COMMAND_ENABLED) helpMenu += "```\n***_To assign yourself to an issue, please open a draft pull request that is linked to it._***";
+ if (!startEnabled) helpMenu += "```\n***_To assign yourself to an issue, please open a draft pull request that is linked to it._***";
return helpMenu;
};
diff --git a/src/handlers/comment/handlers/index.ts b/src/handlers/comment/handlers/index.ts
index 9f7276f3e..4dcbf7f21 100644
--- a/src/handlers/comment/handlers/index.ts
+++ b/src/handlers/comment/handlers/index.ts
@@ -1,5 +1,4 @@
-import { getBotConfig } from "../../../bindings";
-import { Payload, UserCommands } from "../../../types";
+import { Comment, Payload, UserCommands } from "../../../types";
import { IssueCommentCommands } from "../commands";
import { assign } from "./assign";
import { listAvailableCommands } from "./help";
@@ -7,11 +6,38 @@ import { listAvailableCommands } from "./help";
// import { payout } from "./payout";
import { unassign } from "./unassign";
import { registerWallet } from "./wallet";
-import { setAccess } from "./set-access";
+import { approveLabelChange } from "./authorize";
+import { setAccess } from "./allow";
+import { ask } from "./ask";
import { multiplier } from "./multiplier";
-import { addCommentToIssue, createLabel, addLabelToIssue } from "../../../helpers";
-import { getBotContext } from "../../../bindings";
-import { handleIssueClosed } from "../../payout";
+import { BigNumber, ethers } from "ethers";
+import { addPenalty } from "../../../adapters/supabase";
+import {
+ addCommentToIssue,
+ createLabel,
+ addLabelToIssue,
+ getLabel,
+ upsertCommentToIssue,
+ getAllIssueComments,
+ getPayoutConfigByNetworkId,
+ getTokenSymbol,
+ getAllIssueAssignEvents,
+ calculateWeight,
+} from "../../../helpers";
+import { getBotConfig, getBotContext, getLogger } from "../../../bindings";
+import {
+ handleIssueClosed,
+ incentivesCalculation,
+ calculateIssueConversationReward,
+ calculateIssueCreatorReward,
+ calculateIssueAssigneeReward,
+ calculatePullRequestReviewsReward,
+} from "../../payout";
+import { query } from "./query";
+import { autoPay } from "./payout";
+import { getTargetPriceLabel } from "../../shared";
+import Decimal from "decimal.js";
+import { ErrorDiff } from "../../../utils/helpers";
export * from "./assign";
export * from "./wallet";
@@ -19,6 +45,25 @@ export * from "./unassign";
export * from "./payout";
export * from "./help";
export * from "./multiplier";
+export * from "./query";
+export * from "./ask";
+export * from "./authorize";
+
+export interface RewardsResponse {
+ error: string | null;
+ title?: string;
+ userId?: number;
+ username?: string;
+ reward?: {
+ account: string;
+ priceInEth: Decimal;
+ penaltyAmount: BigNumber;
+ user: string;
+ userId: number;
+ debug: Record;
+ }[];
+ fallbackReward?: Record;
+}
/**
* Parses the comment body and figure out the command name a user wants
@@ -27,12 +72,19 @@ export * from "./multiplier";
* @param body - The comment body
* @returns The list of command names the comment includes
*/
+
export const commentParser = (body: string): IssueCommentCommands[] => {
- // TODO: As a starting point, it may be simple but there could be cases for the comment to includes one or more commands
- // We need to continuously improve to parse even complex comments. Right now, we implement it simply.
- const commandList = Object.values(IssueCommentCommands) as string[];
- const result = commandList.filter((command: string) => body.startsWith(command));
- return result as IssueCommentCommands[];
+ const regex = /^\/(\w+)\b/; // Regex pattern to match the command at the beginning of the body
+
+ const matches = regex.exec(body);
+ if (matches) {
+ const command = matches[0] as IssueCommentCommands;
+ if (Object.values(IssueCommentCommands).includes(command)) {
+ return [command];
+ }
+ }
+
+ return [];
};
/**
@@ -41,14 +93,24 @@ export const commentParser = (body: string): IssueCommentCommands[] => {
export const issueClosedCallback = async (): Promise => {
const { payload: _payload } = getBotContext();
- const { comments } = getBotConfig();
const issue = (_payload as Payload).issue;
if (!issue) return;
try {
- const comment = await handleIssueClosed();
- if (comment) await addCommentToIssue(comment + comments.promotionComment, issue.number);
+ // assign function incentivesCalculation to a variable
+ const calculateIncentives = await incentivesCalculation();
+
+ const creatorReward = await calculateIssueCreatorReward(calculateIncentives);
+ const assigneeReward = await calculateIssueAssigneeReward(calculateIncentives);
+ const conversationRewards = await calculateIssueConversationReward(calculateIncentives);
+ const pullRequestReviewersReward = await calculatePullRequestReviewsReward(calculateIncentives);
+
+ const { error } = await handleIssueClosed(creatorReward, assigneeReward, conversationRewards, pullRequestReviewersReward, calculateIncentives);
+
+ if (error) {
+ throw new Error(error);
+ }
} catch (err: unknown) {
- return await addCommentToIssue(`Error: ${err}`, issue.number);
+ return await addCommentToIssue(ErrorDiff(err), issue.number);
}
};
@@ -57,24 +119,120 @@ export const issueClosedCallback = async (): Promise => {
*/
export const issueCreatedCallback = async (): Promise => {
+ const logger = getLogger();
const { payload: _payload } = getBotContext();
const config = getBotConfig();
const issue = (_payload as Payload).issue;
if (!issue) return;
const labels = issue.labels;
+
+ const { assistivePricing } = config.mode;
+
+ if (!assistivePricing) {
+ logger.info("Skipping adding label to issue because assistive pricing is disabled.");
+ return;
+ }
+
try {
- const timeLabelConfigs = config.price.timeLabels.sort((label1, label2) => label1.weight - label2.weight);
- const priorityLabelConfigs = config.price.priorityLabels.sort((label1, label2) => label1.weight - label2.weight);
const timeLabels = config.price.timeLabels.filter((item) => labels.map((i) => i.name).includes(item.name));
const priorityLabels = config.price.priorityLabels.filter((item) => labels.map((i) => i.name).includes(item.name));
- if (timeLabels.length === 0 && timeLabelConfigs.length > 0) await createLabel(timeLabelConfigs[0].name);
- if (priorityLabels.length === 0 && priorityLabelConfigs.length > 0) await createLabel(priorityLabelConfigs[0].name);
- await addLabelToIssue(timeLabelConfigs[0].name);
- await addLabelToIssue(priorityLabelConfigs[0].name);
- return;
+ const minTimeLabel =
+ timeLabels.length > 0 ? timeLabels.reduce((a, b) => (calculateWeight(a) < calculateWeight(b) ? a : b)).name : config.price.defaultLabels[0];
+ const minPriorityLabel =
+ priorityLabels.length > 0 ? priorityLabels.reduce((a, b) => (calculateWeight(a) < calculateWeight(b) ? a : b)).name : config.price.defaultLabels[1];
+ if (!timeLabels.length) await addLabelToIssue(minTimeLabel);
+ if (!priorityLabels.length) await addLabelToIssue(minPriorityLabel);
+
+ const targetPriceLabel = getTargetPriceLabel(minTimeLabel, minPriorityLabel);
+ if (targetPriceLabel && !labels.map((i) => i.name).includes(targetPriceLabel)) {
+ const exist = await getLabel(targetPriceLabel);
+ if (!exist) await createLabel(targetPriceLabel, "price");
+ await addLabelToIssue(targetPriceLabel);
+ }
+ } catch (err: unknown) {
+ await addCommentToIssue(ErrorDiff(err), issue.number);
+ }
+};
+
+/**
+ * Callback for issues reopened - Processor
+ */
+
+export const issueReopenedCallback = async (): Promise => {
+ const { payload: _payload } = getBotContext();
+ const {
+ payout: { permitBaseUrl },
+ } = getBotConfig();
+ const logger = getLogger();
+ const issue = (_payload as Payload).issue;
+ const repository = (_payload as Payload).repository;
+ if (!issue) return;
+ try {
+ // find permit comment from the bot
+ const comments = await getAllIssueComments(issue.number);
+ const claimUrlRegex = new RegExp(`\\((${permitBaseUrl}\\?claim=\\S+)\\)`);
+ const permitCommentIdx = comments.findIndex((e) => e.user.type === "Bot" && e.body.match(claimUrlRegex));
+ if (permitCommentIdx === -1) {
+ return;
+ }
+
+ // extract permit amount and token
+ const permitComment = comments[permitCommentIdx];
+ const permitUrl = permitComment.body.match(claimUrlRegex);
+ if (!permitUrl || permitUrl.length < 2) {
+ logger.error(`Permit URL not found`);
+ return;
+ }
+ const url = new URL(permitUrl[1]);
+ const claimBase64 = url.searchParams.get("claim");
+ if (!claimBase64) {
+ logger.error(`Permit claim search parameter not found`);
+ return;
+ }
+ let networkId = url.searchParams.get("network");
+ if (!networkId) {
+ networkId = "1";
+ }
+ const { rpc } = getPayoutConfigByNetworkId(Number(networkId));
+ let claim;
+ try {
+ claim = JSON.parse(Buffer.from(claimBase64, "base64").toString("utf-8"));
+ } catch (err: unknown) {
+ logger.error(`Error parsing claim: ${err}`);
+ return;
+ }
+ const amount = BigNumber.from(claim.permit.permitted.amount);
+ const formattedAmount = ethers.utils.formatUnits(amount, 18);
+ const tokenAddress = claim.permit.permitted.token;
+ const tokenSymbol = await getTokenSymbol(tokenAddress, rpc);
+
+ // find latest assignment before the permit comment
+ const events = await getAllIssueAssignEvents(issue.number);
+ if (events.length === 0) {
+ logger.error(`No assignment found`);
+ return;
+ }
+ const assignee = events[0].assignee.login;
+
+ if (parseFloat(formattedAmount) > 0) {
+ // write penalty to db
+ try {
+ await addPenalty(assignee, repository.full_name, tokenAddress, networkId.toString(), amount);
+ } catch (err) {
+ logger.error(`Error writing penalty to db: ${err}`);
+ return;
+ }
+
+ await addCommentToIssue(
+ `@${assignee} please be sure to review this conversation and implement any necessary fixes. Unless this is closed as completed, its payment of **${formattedAmount} ${tokenSymbol}** will be deducted from your next bounty.`,
+ issue.number
+ );
+ } else {
+ logger.info(`Skipped penalty because amount is 0`);
+ }
} catch (err: unknown) {
- return await addCommentToIssue(`Error: ${err}`, issue.number);
+ await addCommentToIssue(ErrorDiff(err), issue.number);
}
};
@@ -85,52 +243,83 @@ export const issueCreatedCallback = async (): Promise => {
* @param issue_number - The issue number
* @param comment - Comment string
*/
-const commandCallback = async (issue_number: number, comment: string) => {
- await addCommentToIssue(comment, issue_number);
+
+const commandCallback = async (issue_number: number, comment: string, action: string, reply_to?: Comment) => {
+ await upsertCommentToIssue(issue_number, comment, action, reply_to);
};
-export const userCommands: UserCommands[] = [
- {
- id: IssueCommentCommands.ASSIGN,
- description: "Assign the origin sender to the issue automatically.",
- handler: assign,
- callback: commandCallback,
- },
- {
- id: IssueCommentCommands.UNASSIGN,
- description: "Unassign the origin sender from the issue automatically.",
- handler: unassign,
- callback: commandCallback,
- },
- {
- handler: listAvailableCommands,
- id: IssueCommentCommands.HELP,
- description: "List all available commands.",
- callback: commandCallback,
- },
- // Commented out until Gnosis Safe is integrated (https://github.com/ubiquity/ubiquibot/issues/353)
- /*{
+export const userCommands = (): UserCommands[] => {
+ const config = getBotConfig();
+
+ return [
+ {
+ id: IssueCommentCommands.START,
+ description: "Assign the origin sender to the issue automatically.",
+ handler: assign,
+ callback: commandCallback,
+ },
+ {
+ id: IssueCommentCommands.STOP,
+ description: "Unassign the origin sender from the issue automatically.",
+ handler: unassign,
+ callback: commandCallback,
+ },
+ {
+ handler: listAvailableCommands,
+ id: IssueCommentCommands.HELP,
+ description: "List all available commands.",
+ callback: commandCallback,
+ },
+ // Commented out until Gnosis Safe is integrated (https://github.com/ubiquity/ubiquibot/issues/353)
+ /*{
id: IssueCommentCommands.PAYOUT,
description: "Disable automatic payment for the issue.",
handler: payout,
callback: commandCallback,
},*/
- {
- id: IssueCommentCommands.MULTIPLIER,
- description: `Set the bounty payout multiplier for a specific contributor, and provide the reason for why. \n example usage: "/wallet @user 0.5 'Multiplier reason'"`,
- handler: multiplier,
- callback: commandCallback,
- },
- {
- id: IssueCommentCommands.ALLOW,
- description: `Set access control. (Admin Only)`,
- handler: setAccess,
- callback: commandCallback,
- },
- {
- id: IssueCommentCommands.WALLET,
- description: `: Register the hunter's wallet address. \n ex1: /wallet 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 \n ex2: /wallet vitalik.eth\n`,
- handler: registerWallet,
- callback: commandCallback,
- },
-];
+ {
+ id: IssueCommentCommands.AUTOPAY,
+ description: "Toggle automatic payment for the completion of the current issue.",
+ handler: autoPay,
+ callback: commandCallback,
+ },
+ {
+ id: IssueCommentCommands.QUERY,
+ description: `Comments the users multiplier and address`,
+ handler: query,
+ callback: commandCallback,
+ },
+ {
+ id: IssueCommentCommands.ASK,
+ description: `Ask a technical question to the Ubiquity AI. \n example usage: "/ask How do I do X?"`,
+ handler: ask,
+ callback: commandCallback,
+ },
+ {
+ id: IssueCommentCommands.MULTIPLIER,
+ description: `Set the bounty payout multiplier for a specific contributor, and provide the reason for why. \n example usage: "/wallet @user 0.5 'Multiplier reason'"`,
+ handler: multiplier,
+ callback: commandCallback,
+ },
+ {
+ id: IssueCommentCommands.ALLOW,
+ description: `Set access control. (Admin Only)`,
+ handler: setAccess,
+ callback: commandCallback,
+ },
+ {
+ id: IssueCommentCommands.AUTHORIZE,
+ description: `Approve a label change. Superuser only.`,
+ handler: approveLabelChange,
+ callback: commandCallback,
+ },
+ {
+ id: IssueCommentCommands.WALLET,
+ description: config.wallet.registerWalletWithVerification
+ ? `: Register the hunter's wallet address. \n Your message to sign is: DevPool\n You can generate SIGNATURE_HASH at https://etherscan.io/verifiedSignatures\n ex1: /wallet 0x0000000000000000000000000000000000000000 0xe2a3e34a63f3def2c29605de82225b79e1398190b542be917ef88a8e93ff9dc91bdc3ef9b12ed711550f6d2cbbb50671aa3f14a665b709ec391f3e603d0899a41b\n ex2: /wallet vitalik.eth 0x75329f883590507e581cd6dfca62680b6cd12e1f1665db8097f9e642ed70025146b5cf9f777dde90c4a9cbd41500a6bf76bc394fd0b0cae2aab09f7a6f30e3b31b\n`
+ : `: Register the hunter's wallet address. \n ex1: /wallet 0x0000000000000000000000000000000000000000\n ex2: /wallet vitalik.eth\n`,
+ handler: registerWallet,
+ callback: commandCallback,
+ },
+ ];
+};
diff --git a/src/handlers/comment/handlers/multiplier.ts b/src/handlers/comment/handlers/multiplier.ts
index 7f55d12ae..a7cc674eb 100644
--- a/src/handlers/comment/handlers/multiplier.ts
+++ b/src/handlers/comment/handlers/multiplier.ts
@@ -9,13 +9,16 @@ export const multiplier = async (body: string) => {
const payload = context.payload as Payload;
const sender = payload.sender.login;
const repo = payload.repository;
+ const { repository, organization } = payload;
+
+ const id = organization?.id || repository?.id; // repository?.id as fallback
logger.info(`Received '/multiplier' command from user: ${sender}`);
const issue = payload.issue;
if (!issue) {
logger.info(`Skipping '/multiplier' because of no issue instance`);
- return;
+ return `Skipping '/multiplier' because of no issue instance`;
}
const regex = /(".*?"|[^"\s]+)(?=\s*|\s*$)/g;
@@ -46,7 +49,7 @@ export const multiplier = async (body: string) => {
} else if (part.startsWith("@")) {
username = part.substring(1);
} else {
- reason += part.replace(/['"]/g, "") + " ";
+ reason += part.replace(/['"]/g, "");
}
}
username = username || sender;
@@ -65,15 +68,20 @@ export const multiplier = async (body: string) => {
return "Insufficient permissions to update the payout multiplier. You are not an `admin` or `billing_manager`";
}
}
-
logger.info(`Upserting to the wallet table, username: ${username}, bountyMultiplier: ${bountyMultiplier}, reason: ${reason}}`);
- await upsertWalletMultiplier(username, bountyMultiplier?.toString(), reason);
- return `Successfully changed the payout multiplier for @${username} to ${bountyMultiplier}. The reason ${
- reason ? `provided is "${reason}"` : "is not provided"
- }.`;
+ await upsertWalletMultiplier(username, bountyMultiplier?.toString(), reason, id?.toString());
+ if (bountyMultiplier > 1) {
+ return `Successfully changed the payout multiplier for @${username} to ${bountyMultiplier}. The reason ${
+ reason ? `provided is "${reason}"` : "is not provided"
+ }. This feature is designed to limit the contributor's compensation for any bounty on the current repository due to other compensation structures (i.e. salary.) are you sure you want to use a bounty multiplier above 1?`;
+ } else {
+ return `Successfully changed the payout multiplier for @${username} to ${bountyMultiplier}. The reason ${
+ reason ? `provided is "${reason}"` : "is not provided"
+ }.`;
+ }
} else {
logger.error("Invalid body for bountyMultiplier command");
- return `Invalid body for bountyMultiplier command`;
+ return `Invalid syntax for wallet command \n example usage: "/multiplier @user 0.5 'Multiplier reason'"`;
}
};
diff --git a/src/handlers/comment/handlers/payout.ts b/src/handlers/comment/handlers/payout.ts
index da07f5fdb..d5aabc7d6 100644
--- a/src/handlers/comment/handlers/payout.ts
+++ b/src/handlers/comment/handlers/payout.ts
@@ -1,8 +1,16 @@
import { getBotContext, getLogger } from "../../../bindings";
import { Payload } from "../../../types";
import { IssueCommentCommands } from "../commands";
-import { handleIssueClosed } from "../../payout";
-import { getAllIssueComments } from "../../../helpers";
+import {
+ calculateIssueAssigneeReward,
+ calculateIssueConversationReward,
+ calculateIssueCreatorReward,
+ calculatePullRequestReviewsReward,
+ handleIssueClosed,
+ incentivesCalculation,
+} from "../../payout";
+import { getAllIssueComments, getUserPermission } from "../../../helpers";
+import { GLOBAL_STRINGS } from "../../../configs";
export const payout = async (body: string) => {
const { payload: _payload } = getBotContext();
@@ -37,6 +45,36 @@ export const payout = async (body: string) => {
return;
}
- const response = await handleIssueClosed();
- return response;
+ // assign function incentivesCalculation to a variable
+ const calculateIncentives = await incentivesCalculation();
+
+ const creatorReward = await calculateIssueCreatorReward(calculateIncentives);
+ const assigneeReward = await calculateIssueAssigneeReward(calculateIncentives);
+ const conversationRewards = await calculateIssueConversationReward(calculateIncentives);
+ const pullRequestReviewersReward = await calculatePullRequestReviewsReward(calculateIncentives);
+
+ return await handleIssueClosed(creatorReward, assigneeReward, conversationRewards, pullRequestReviewersReward, calculateIncentives);
+};
+
+export const autoPay = async (body: string) => {
+ const context = getBotContext();
+ const _payload = context.payload;
+ const logger = getLogger();
+
+ const payload = _payload as Payload;
+ logger.info(`Received '/autopay' command from user: ${payload.sender.login}`);
+
+ const pattern = /^\/autopay (true|false)$/;
+ const res = body.match(pattern);
+
+ if (res) {
+ const userPermission = await getUserPermission(payload.sender.login, context);
+ if (userPermission !== "admin" && userPermission !== "billing_manager") {
+ return "You must be an `admin` or `billing_manager` to toggle automatic payments for completed issues.";
+ }
+ if (res.length > 1) {
+ return `${GLOBAL_STRINGS.autopayComment} **${res[1]}**`;
+ }
+ }
+ return "Invalid body for autopay command: e.g. /autopay false";
};
diff --git a/src/handlers/comment/handlers/query.ts b/src/handlers/comment/handlers/query.ts
new file mode 100644
index 000000000..c80f1f15a
--- /dev/null
+++ b/src/handlers/comment/handlers/query.ts
@@ -0,0 +1,63 @@
+import { getAllAccessLevels, getWalletInfo, upsertAccessControl } from "../../../adapters/supabase";
+import { getBotContext, getLogger } from "../../../bindings";
+import { Payload } from "../../../types";
+import { ErrorDiff } from "../../../utils/helpers";
+
+export const query = async (body: string) => {
+ const context = getBotContext();
+ const logger = getLogger();
+ const payload = context.payload as Payload;
+ const sender = payload.sender.login;
+ const { repository, organization } = payload;
+
+ const id = organization?.id || repository?.id; // repository?.id as fallback
+
+ logger.info(`Received '/query' command from user: ${sender}`);
+
+ const issue = payload.issue;
+ if (!issue) {
+ logger.info(`Skipping '/query' because of no issue instance`);
+ return `Skipping '/query' because of no issue instance`;
+ }
+
+ const regex = /^\/query\s+@([\w-]+)\s*$/;
+ const matches = body.match(regex);
+ const user = matches?.[1];
+ const repo = payload.repository;
+
+ if (user) {
+ let data = await getAllAccessLevels(user, repo.full_name);
+ if (!data) {
+ logger.info(`Access info does not exist for @${user}`);
+ try {
+ await upsertAccessControl(user, repo.full_name, "time_access", true);
+ data = {
+ multiplier: false,
+ priority: false,
+ time: true,
+ price: false,
+ };
+ } catch (e) {
+ ErrorDiff(e);
+ return `Error upserting access info for @${user}`;
+ }
+ }
+ const walletInfo = await getWalletInfo(user, id?.toString());
+ if (!walletInfo?.address) {
+ return `Error retrieving multiplier and wallet address for @${user}`;
+ } else {
+ return `@${user}'s wallet address is ${walletInfo?.address}, multiplier is ${walletInfo?.multiplier} and access levels are
+
+| access type | access level |
+| ----------- | ------------------- |
+| multiplier | ${data.multiplier} |
+| priority | ${data.priority} |
+| time | ${data.time} |
+| price | ${data.price} |
+ `;
+ }
+ } else {
+ logger.error("Invalid body for query command");
+ return `Invalid syntax for query command \n usage /query @user`;
+ }
+};
diff --git a/src/handlers/comment/handlers/table.ts b/src/handlers/comment/handlers/table.ts
index fef8e181e..97f154e0b 100644
--- a/src/handlers/comment/handlers/table.ts
+++ b/src/handlers/comment/handlers/table.ts
@@ -4,41 +4,35 @@ export const tableComment = ({
multiplier,
reason,
bounty,
+ isBountyStale,
+ days,
}: {
deadline: string;
wallet: string;
- multiplier: string;
- reason: string;
- bounty: string;
+ multiplier?: string;
+ reason?: string;
+ bounty?: string;
+ isBountyStale?: boolean;
+ days?: number;
}) => {
return `
-
-
-
-
-
-
-
-
Deadline
-
${deadline}
-
-
-
Registered Wallet
-
${wallet}
-
-
-
Payment Multiplier
-
${multiplier}
-
-
-
Multiplier Reason
-
${reason}
-
-
-
Total Bounty
-
${bounty}
-
-
-`;
+
+${
+ isBountyStale
+ ? `
Warning!
This task was created over ${days} days ago. Please confirm that this issue specification is accurate before starting.
`
+ : ``
+}
+
+
Deadline
+
${deadline}
+
+
+
Registered Wallet
+
${wallet}
+
+${multiplier ? `
Payment Multiplier
${multiplier}
` : ``}
+${reason ? `
Multiplier Reason
${reason}
` : ``}
+${bounty ? `
Total Bounty
${bounty}
` : ``}
+
`;
};
diff --git a/src/handlers/comment/handlers/unassign.ts b/src/handlers/comment/handlers/unassign.ts
index cf1e0e3de..91aae184e 100644
--- a/src/handlers/comment/handlers/unassign.ts
+++ b/src/handlers/comment/handlers/unassign.ts
@@ -2,21 +2,21 @@ import { removeAssignees } from "../../../helpers";
import { getBotContext, getLogger } from "../../../bindings";
import { Payload } from "../../../types";
import { IssueCommentCommands } from "../commands";
-import { closePullRequestForAnIssue } from "../..";
+import { closePullRequestForAnIssue } from "../../assign/index";
export const unassign = async (body: string) => {
const { payload: _payload } = getBotContext();
const logger = getLogger();
- if (body != IssueCommentCommands.UNASSIGN && body.replace(/`/g, "") != IssueCommentCommands.UNASSIGN) {
+ if (body != IssueCommentCommands.STOP && body.replace(/`/g, "") != IssueCommentCommands.STOP) {
logger.info(`Skipping to unassign. body: ${body}`);
return;
}
const payload = _payload as Payload;
- logger.info(`Received '/unassign' command from user: ${payload.sender.login}`);
+ logger.info(`Received '/stop' command from user: ${payload.sender.login}`);
const issue = (_payload as Payload).issue;
if (!issue) {
- logger.info(`Skipping '/unassign' because of no issue instance`);
+ logger.info(`Skipping '/stop' because of no issue instance`);
return;
}
diff --git a/src/handlers/comment/handlers/wallet.ts b/src/handlers/comment/handlers/wallet.ts
index a100360a1..6cabfd357 100644
--- a/src/handlers/comment/handlers/wallet.ts
+++ b/src/handlers/comment/handlers/wallet.ts
@@ -1,10 +1,11 @@
+import { ethers } from "ethers";
import { upsertWalletAddress } from "../../../adapters/supabase";
-import { getBotContext, getLogger } from "../../../bindings";
+import { getBotConfig, getBotContext, getLogger } from "../../../bindings";
import { resolveAddress } from "../../../helpers";
import { Payload } from "../../../types";
import { formatEthAddress } from "../../../utils";
import { IssueCommentCommands } from "../commands";
-
+import { constants } from "ethers";
// Extracts ensname from raw text.
const extractEnsName = (text: string): string | undefined => {
// Define a regular expression to match ENS names
@@ -23,6 +24,7 @@ const extractEnsName = (text: string): string | undefined => {
export const registerWallet = async (body: string) => {
const { payload: _payload } = getBotContext();
+ const config = getBotConfig();
const logger = getLogger();
const payload = _payload as Payload;
const sender = payload.sender.login;
@@ -34,7 +36,10 @@ export const registerWallet = async (body: string) => {
if (!address && !ensName) {
logger.info("Skipping to register a wallet address because both address/ens doesn't exist");
- return;
+ if (config.wallet.registerWalletWithVerification) {
+ return `Please include your wallet or ENS address.\n usage: /wallet 0x0000000000000000000000000000000000000000 0x0830f316c982a7fd4ff050c8fdc1212a8fd92f6bb42b2337b839f2b4e156f05a359ef8f4acd0b57cdedec7874a865ee07076ab2c81dc9f9de28ced55228587f81c`;
+ }
+ return `Please include your wallet or ENS address.\n usage: /wallet 0x0000000000000000000000000000000000000000`;
}
if (!address && ensName) {
@@ -47,7 +52,33 @@ export const registerWallet = async (body: string) => {
logger.info(`Resolved address from Ens name: ${ensName}, address: ${address}`);
}
+ if (config.wallet.registerWalletWithVerification) {
+ const regexForSigHash = /(0x[a-fA-F0-9]{130})/g;
+ const sigHashMatches = body.match(regexForSigHash);
+ const sigHash = sigHashMatches ? sigHashMatches[0] : null;
+
+ const messageToSign = "DevPool";
+ const failedSigLogMsg = `Skipping to register the wallet address because you have not provided a valid SIGNATURE_HASH.`;
+ const failedSigResponse = `Skipping to register the wallet address because you have not provided a valid SIGNATURE_HASH. \nUse [etherscan](https://etherscan.io/verifiedSignatures) to sign the message \`${messageToSign}\` and register your wallet by appending the signature hash.\n\n**Usage:**\n/wallet \n\n**Example:**\n/wallet 0x0000000000000000000000000000000000000000 0x0830f316c982a7fd4ff050c8fdc1212a8fd92f6bb42b2337b839f2b4e156f05a359ef8f4acd0b57cdedec7874a865ee07076ab2c81dc9f9de28ced55228587f81c`;
+ try {
+ //verifyMessage throws an error when some parts(r,s,v) of the signature are correct but some are not
+ const isSigHashValid = address && sigHash && ethers.utils.verifyMessage(messageToSign, sigHash) == ethers.utils.getAddress(address);
+ if (!isSigHashValid) {
+ logger.info(failedSigLogMsg);
+ return failedSigResponse;
+ }
+ } catch (e) {
+ logger.info(`Exception thrown by verifyMessage for /wallet: ${e}`);
+ logger.info(failedSigLogMsg);
+ return failedSigResponse;
+ }
+ }
+
if (address) {
+ if (address == constants.AddressZero) {
+ logger.info("Skipping to register a wallet address because user is trying to set their address to null address");
+ return `Cannot set address to null address`;
+ }
await upsertWalletAddress(sender, address);
return `Updated the wallet address for @${sender} successfully!\t Your new address: ${formatEthAddress(address)}`;
}
diff --git a/src/handlers/issue/index.ts b/src/handlers/issue/index.ts
new file mode 100644
index 000000000..12e1212ae
--- /dev/null
+++ b/src/handlers/issue/index.ts
@@ -0,0 +1 @@
+export * from "./pre";
diff --git a/src/handlers/issue/pre.ts b/src/handlers/issue/pre.ts
new file mode 100644
index 000000000..bebda5dce
--- /dev/null
+++ b/src/handlers/issue/pre.ts
@@ -0,0 +1,49 @@
+import { extractImportantWords, upsertCommentToIssue, measureSimilarity } from "../../helpers";
+import { getBotContext, getLogger } from "../../bindings";
+import { Issue, Payload } from "../../types";
+
+export const findDuplicateOne = async () => {
+ const logger = getLogger();
+ const context = getBotContext();
+ const payload = context.payload as Payload;
+ const issue = payload.issue;
+
+ if (!issue?.body) return;
+ const importantWords = await extractImportantWords(issue);
+ const perPage = 10;
+ let curPage = 1;
+
+ for (const importantWord of importantWords) {
+ let fetchDone = false;
+ try {
+ while (!fetchDone) {
+ const response = await context.octokit.rest.search.issuesAndPullRequests({
+ q: `${importantWord} repo:${payload.repository.owner.login}/${payload.repository.name} is:issue`,
+ sort: "created",
+ order: "desc",
+ per_page: perPage,
+ page: curPage,
+ });
+ if (response.data.items.length > 0) {
+ for (const result of response.data.items) {
+ if (!result.body) continue;
+ if (result.id === issue.id) continue;
+ const similarity = await measureSimilarity(issue, result as Issue);
+ if (similarity > parseInt(process.env.SIMILARITY_THRESHOLD || "80")) {
+ await upsertCommentToIssue(
+ issue.number,
+ `Similar issue (${result.title}) found at ${result.html_url}.\nSimilarity is about ${similarity}%`,
+ "created"
+ );
+ return;
+ }
+ }
+ }
+ if (response.data.items.length < perPage) fetchDone = true;
+ else curPage++;
+ }
+ } catch (e: unknown) {
+ logger.error(`Could not find any issues, reason: ${e}`);
+ }
+ }
+};
diff --git a/src/handlers/label/index.ts b/src/handlers/label/index.ts
new file mode 100644
index 000000000..491d49f74
--- /dev/null
+++ b/src/handlers/label/index.ts
@@ -0,0 +1,30 @@
+import { saveLabelChange } from "../../adapters/supabase";
+import { getBotContext, getLogger } from "../../bindings";
+import { hasLabelEditPermission } from "../../helpers";
+import { Payload } from "../../types";
+
+export const watchLabelChange = async () => {
+ const logger = getLogger();
+ const context = getBotContext();
+
+ const payload = context.payload as Payload;
+
+ const { repository, label, changes, sender } = payload;
+
+ const { full_name } = repository;
+
+ const previousLabel = changes?.name.from;
+ const currentLabel = label?.name;
+ const triggerUser = sender.login;
+
+ if (!previousLabel || !currentLabel) {
+ logger.debug("watchLabelChange: No label name change.. skipping");
+ return;
+ }
+
+ // check if user is authorized to make the change
+ const hasAccess = await hasLabelEditPermission(currentLabel, triggerUser, repository.full_name);
+
+ await saveLabelChange(triggerUser, full_name, previousLabel, currentLabel, hasAccess);
+ logger.debug("watchLabelChange: label name change saved to db");
+};
diff --git a/src/handlers/payout/action.ts b/src/handlers/payout/action.ts
index 03ef97e35..25bf01562 100644
--- a/src/handlers/payout/action.ts
+++ b/src/handlers/payout/action.ts
@@ -1,85 +1,521 @@
-import { getWalletAddress, getWalletMultiplier } from "../../adapters/supabase";
+import { BigNumber, ethers } from "ethers";
+import { getLabelChanges, getPenalty, getWalletAddress, getWalletMultiplier, removePenalty } from "../../adapters/supabase";
import { getBotConfig, getBotContext, getLogger } from "../../bindings";
-import { addLabelToIssue, deleteLabel, generatePermit2Signature, getAllIssueComments, getTokenSymbol } from "../../helpers";
-import {UserType, Payload, StateReason } from "../../types";
-import { shortenEthAddress } from "../../utils";
+import {
+ addLabelToIssue,
+ checkUserPermissionForRepoAndOrg,
+ clearAllPriceLabelsOnIssue,
+ deleteLabel,
+ generatePermit2Signature,
+ getAllIssueComments,
+ getTokenSymbol,
+ wasIssueReopened,
+ getAllIssueAssignEvents,
+ addCommentToIssue,
+ createDetailsTable,
+ savePermitToDB,
+} from "../../helpers";
+import { UserType, Payload, StateReason, Comment, User, Incentives, Issue } from "../../types";
import { bountyInfo } from "../wildcard";
+import Decimal from "decimal.js";
+import { GLOBAL_STRINGS } from "../../configs";
+import { isParentIssue } from "../pricing";
+import { RewardsResponse } from "../comment";
-export const handleIssueClosed = async () => {
+export interface IncentivesCalculationResult {
+ id: number;
+ paymentToken: string;
+ rpc: string;
+ networkId: number;
+ privateKey: string;
+ paymentPermitMaxPrice: number;
+ baseMultiplier: number;
+ incentives: Incentives;
+ issueCreatorMultiplier: number;
+ recipient: string;
+ multiplier: number;
+ issue: Issue;
+ payload: Payload;
+ comments: Comment[];
+ issueDetailed: {
+ isBounty: boolean;
+ timelabel: string;
+ priorityLabel: string;
+ priceLabel: string;
+ };
+ assignee: User;
+ tokenSymbol: string;
+ claimUrlRegex: RegExp;
+}
+
+export interface RewardByUser {
+ account: string;
+ priceInEth: Decimal;
+ userId: number | undefined;
+ issueId: string;
+ type: (string | undefined)[];
+ user: string | undefined;
+ priceArray: string[];
+ debug: Record;
+}
+
+/**
+ * Collect the information required for the permit generation and error handling
+ */
+
+export const incentivesCalculation = async (): Promise => {
const context = getBotContext();
const {
- payout: { paymentToken, rpc },
- mode: { autoPayMode },
+ payout: { paymentToken, rpc, permitBaseUrl, networkId, privateKey },
+ mode: { incentiveMode, paymentPermitMaxPrice },
+ price: { incentives, issueCreatorMultiplier, baseMultiplier },
+ accessControl,
} = getBotConfig();
const logger = getLogger();
const payload = context.payload as Payload;
const issue = payload.issue;
- if (!issue) return;
+ const { repository, organization } = payload;
+
+ const id = organization?.id || repository?.id; // repository?.id as fallback
+
+ if (!issue) {
+ throw new Error("Permit generation skipped because issue is undefined");
+ }
+
+ if (accessControl.organization) {
+ const userHasPermission = await checkUserPermissionForRepoAndOrg(payload.sender.login, context);
+
+ if (!userHasPermission) {
+ throw new Error("Permit generation disabled because this issue has been closed by an external contributor.");
+ }
+ }
+
+ const comments = await getAllIssueComments(issue.number);
+
+ const wasReopened = await wasIssueReopened(issue.number);
+ const claimUrlRegex = new RegExp(`\\((${permitBaseUrl}\\?claim=\\S+)\\)`);
+ const permitCommentIdx = comments.findIndex((e) => e.user.type === UserType.Bot && e.body.match(claimUrlRegex));
+
+ if (wasReopened && permitCommentIdx !== -1) {
+ const permitComment = comments[permitCommentIdx];
+ const permitUrl = permitComment.body.match(claimUrlRegex);
+ if (!permitUrl || permitUrl.length < 2) {
+ logger.error(`Permit URL not found`);
+ throw new Error("Permit generation skipped because permit URL not found");
+ }
+ const url = new URL(permitUrl[1]);
+ const claimBase64 = url.searchParams.get("claim");
+ if (!claimBase64) {
+ logger.error(`Permit claim search parameter not found`);
+ throw new Error("Permit generation skipped because permit claim search parameter not found");
+ }
+ let networkId = url.searchParams.get("network");
+ if (!networkId) {
+ networkId = "1";
+ }
+ let claim;
+ try {
+ claim = JSON.parse(Buffer.from(claimBase64, "base64").toString("utf-8"));
+ } catch (err: unknown) {
+ logger.error(`${err}`);
+ throw new Error("Permit generation skipped because permit claim is invalid");
+ }
+ const amount = BigNumber.from(claim.permit.permitted.amount);
+ const tokenAddress = claim.permit.permitted.token;
+
+ // extract assignee
+ const events = await getAllIssueAssignEvents(issue.number);
+ if (events.length === 0) {
+ logger.error(`No assignment found`);
+ throw new Error("Permit generation skipped because no assignment found");
+ }
+ const assignee = events[0].assignee.login;
+
+ try {
+ await removePenalty(assignee, payload.repository.full_name, tokenAddress, networkId, amount);
+ } catch (err) {
+ logger.error(`Failed to remove penalty: ${err}`);
+ throw new Error("Permit generation skipped because failed to remove penalty");
+ }
+
+ logger.info(`Penalty removed`);
+ throw new Error("Permit generation skipped, penalty removed");
+ }
+
+ if (!incentiveMode) {
+ logger.info(`No incentive mode. skipping to process`);
+ throw new Error("No incentive mode. skipping to process");
+ }
+
+ if (privateKey == "") {
+ logger.info("Permit generation disabled because wallet private key is not set.");
+ throw new Error("Permit generation disabled because wallet private key is not set.");
+ }
if (issue.state_reason !== StateReason.COMPLETED) {
- logger.info("Permit generation skipped because the issue was not closed as completed");
- return "Permit generation skipped because the issue was not closed as completed";
+ logger.info("Permit generation disabled because this is marked as unplanned.");
+ throw new Error("Permit generation disabled because this is marked as unplanned.");
+ }
+
+ logger.info(`Checking if the issue is a parent issue.`);
+ if (issue.body && isParentIssue(issue.body)) {
+ logger.error("Permit generation disabled because this is a collection of issues.");
+ await clearAllPriceLabelsOnIssue();
+ throw new Error("Permit generation disabled because this is a collection of issues.");
}
logger.info(`Handling issues.closed event, issue: ${issue.number}`);
- if (!autoPayMode) {
- logger.info(`Skipping to generate permit2 url, reason: { autoPayMode: ${autoPayMode}}`);
- return `Permit generation skipped since autoPayMode is disabled`;
+ for (const botComment of comments.filter((cmt) => cmt.user.type === UserType.Bot).reverse()) {
+ const botCommentBody = botComment.body;
+ if (botCommentBody.includes(GLOBAL_STRINGS.autopayComment)) {
+ const pattern = /\*\*(\w+)\*\*/;
+ const res = botCommentBody.match(pattern);
+ if (res) {
+ if (res[1] === "false") {
+ logger.info(`Skipping to generate permit2 url, reason: autoPayMode for this issue: false`);
+ throw new Error(`Permit generation disabled because automatic payment for this issue is disabled.`);
+ }
+ break;
+ }
+ }
}
+
+ if (paymentPermitMaxPrice == 0 || !paymentPermitMaxPrice) {
+ logger.info(`Skipping to generate permit2 url, reason: { paymentPermitMaxPrice: ${paymentPermitMaxPrice}}`);
+ throw new Error(`Permit generation disabled because paymentPermitMaxPrice is 0.`);
+ }
+
const issueDetailed = bountyInfo(issue);
if (!issueDetailed.isBounty) {
logger.info(`Skipping... its not a bounty`);
- return `Permit generation skipped since this issue didn't qualify as bounty`;
+ throw new Error(`Permit generation disabled because this issue didn't qualify as bounty.`);
+ }
+
+ if (!issueDetailed.priceLabel || !issueDetailed.priorityLabel || !issueDetailed.timelabel) {
+ logger.info(`Skipping... its not a bounty`);
+ throw new Error(`Permit generation disabled because this issue didn't qualify as bounty.`);
+ }
+
+ // check for label altering here
+ const labelChanges = await getLabelChanges(repository.full_name, [issueDetailed.priceLabel, issueDetailed.priorityLabel, issueDetailed.timelabel]);
+
+ if (labelChanges) {
+ // if approved is still false, it means user was certainly not authorized for that edit
+ if (!labelChanges.approved) {
+ logger.info(`Skipping... label was changed by unauthorized user`);
+ throw new Error(`Permit generation disabled because label: "${labelChanges.label_to}" was modified by an unauthorized user`);
+ }
}
const assignees = issue?.assignees ?? [];
const assignee = assignees.length > 0 ? assignees[0] : undefined;
if (!assignee) {
logger.info("Skipping to proceed the payment because `assignee` is undefined");
- return `Permit generation skipped since assignee is undefined`;
+ throw new Error(`Permit generation disabled because assignee is undefined.`);
}
if (!issueDetailed.priceLabel) {
logger.info("Skipping to proceed the payment because price not set");
- return `Permit generation skipped since price label is not set`;
+ throw new Error(`Permit generation disabled because price label is not set.`);
}
const recipient = await getWalletAddress(assignee.login);
- const multiplier = await getWalletMultiplier(assignee.login);
+ if (!recipient || recipient?.trim() === "") {
+ logger.info(`Recipient address is missing`);
+ throw new Error(`Permit generation skipped because recipient address is missing`);
+ }
+
+ const { value: multiplier } = await getWalletMultiplier(assignee.login, id?.toString());
if (multiplier === 0) {
const errMsg = "Refusing to generate the payment permit because " + `@${assignee.login}` + "'s payment `multiplier` is `0`";
logger.info(errMsg);
- return errMsg;
+ throw new Error(errMsg);
}
- // TODO: add multiplier to the priceInEth
- const priceInEth = (+issueDetailed.priceLabel.substring(7, issueDetailed.priceLabel.length - 4) * multiplier).toString();
- if (!recipient || recipient?.trim() === "") {
- logger.info(`Recipient address is missing`);
- return (
- "Please set your wallet address by using the `/wallet` command.\n" +
- "```\n" +
- "/wallet example.eth\n" +
- "/wallet 0xBf...CdA\n" +
- "```\n" +
- "@" +
- assignee.login
- );
- }
-
- const payoutUrl = await generatePermit2Signature(recipient, priceInEth, issue.node_id);
- const tokenSymbol = await getTokenSymbol(paymentToken, rpc);
- const shortenRecipient = shortenEthAddress(recipient, `[ CLAIM ${priceInEth} ${tokenSymbol.toUpperCase()} ]`.length);
- logger.info(`Posting a payout url to the issue, url: ${payoutUrl}`);
- const comment = `### [ **[ CLAIM ${priceInEth} ${tokenSymbol.toUpperCase()} ]** ](${payoutUrl})\n` + "```" + shortenRecipient + "```";
- const comments = await getAllIssueComments(issue.number);
const permitComments = comments.filter((content) => content.body.includes("https://pay.ubq.fi?claim=") && content.user.type == UserType.Bot);
+
+ if (permitComments.length > 0) {
+ logger.info(`skip to generate a permit url because it has been already posted`);
+ throw new Error(`skip to generate a permit url because it has been already posted`);
+ }
+
+ const tokenSymbol = await getTokenSymbol(paymentToken, rpc);
+
+ return {
+ id,
+ paymentToken,
+ rpc,
+ networkId,
+ privateKey,
+ recipient,
+ multiplier,
+ paymentPermitMaxPrice,
+ baseMultiplier,
+ incentives,
+ issueCreatorMultiplier,
+ issue,
+ payload,
+ comments,
+ issueDetailed: {
+ isBounty: issueDetailed.isBounty,
+ timelabel: issueDetailed.timelabel,
+ priorityLabel: issueDetailed.priorityLabel,
+ priceLabel: issueDetailed.priceLabel,
+ },
+ assignee,
+ tokenSymbol,
+ claimUrlRegex,
+ };
+};
+
+/**
+ * Calculate the reward for the assignee
+ */
+
+export const calculateIssueAssigneeReward = async (incentivesCalculation: IncentivesCalculationResult): Promise => {
+ const logger = getLogger();
+ const assigneeLogin = incentivesCalculation.assignee.login;
+
+ let priceInEth = new Decimal(incentivesCalculation.issueDetailed.priceLabel.substring(7, incentivesCalculation.issueDetailed.priceLabel.length - 4)).mul(
+ incentivesCalculation.multiplier
+ );
+ if (priceInEth.gt(incentivesCalculation.paymentPermitMaxPrice)) {
+ logger.info("Skipping to proceed the payment because bounty payout is higher than paymentPermitMaxPrice.");
+ return { error: `Permit generation disabled because issue's bounty is higher than ${incentivesCalculation.paymentPermitMaxPrice}` };
+ }
+
+ // if bounty hunter has any penalty then deduct it from the bounty
+ const penaltyAmount = await getPenalty(
+ assigneeLogin,
+ incentivesCalculation.payload.repository.full_name,
+ incentivesCalculation.paymentToken,
+ incentivesCalculation.networkId.toString()
+ );
+ if (penaltyAmount.gt(0)) {
+ logger.info(`Deducting penalty from bounty`);
+ const bountyAmount = ethers.utils.parseUnits(priceInEth.toString(), 18);
+ const bountyAmountAfterPenalty = bountyAmount.sub(penaltyAmount);
+ if (bountyAmountAfterPenalty.lte(0)) {
+ await removePenalty(
+ assigneeLogin,
+ incentivesCalculation.payload.repository.full_name,
+ incentivesCalculation.paymentToken,
+ incentivesCalculation.networkId.toString(),
+ bountyAmount
+ );
+ const msg = `Permit generation disabled because bounty amount after penalty is 0.`;
+ logger.info(msg);
+ return { error: msg };
+ }
+ priceInEth = new Decimal(ethers.utils.formatUnits(bountyAmountAfterPenalty, 18));
+ }
+
+ const account = await getWalletAddress(assigneeLogin);
+
+ return {
+ title: "Issue-Assignee",
+ error: "",
+ userId: incentivesCalculation.assignee.id,
+ username: assigneeLogin,
+ reward: [
+ {
+ priceInEth,
+ penaltyAmount,
+ account: account || "0x",
+ user: "",
+ userId: incentivesCalculation.assignee.id,
+ debug: {},
+ },
+ ],
+ };
+};
+
+export const handleIssueClosed = async (
+ creatorReward: RewardsResponse,
+ assigneeReward: RewardsResponse,
+ conversationRewards: RewardsResponse,
+ pullRequestReviewersReward: RewardsResponse,
+ incentivesCalculation: IncentivesCalculationResult
+): Promise<{ error: string }> => {
+ const logger = getLogger();
+ const { comments } = getBotConfig();
+ const issueNumber = incentivesCalculation.issue.number;
+
+ let permitComment = "";
+ const title = ["Issue-Assignee"];
+
+ // Rewards by user
+ const rewardByUser: RewardByUser[] = [];
+
+ // ASSIGNEE REWARD PRICE PROCESSOR
+ const priceInEth = new Decimal(incentivesCalculation.issueDetailed.priceLabel.substring(7, incentivesCalculation.issueDetailed.priceLabel.length - 4)).mul(
+ incentivesCalculation.multiplier
+ );
+
+ if (priceInEth.gt(incentivesCalculation.paymentPermitMaxPrice)) {
+ logger.info("Skipping to proceed the payment because bounty payout is higher than paymentPermitMaxPrice");
+ return { error: `Permit generation skipped since issue's bounty is higher than ${incentivesCalculation.paymentPermitMaxPrice}` };
+ }
+
+ // COMMENTERS REWARD HANDLER
+ if (conversationRewards.reward && conversationRewards.reward.length > 0) {
+ conversationRewards.reward.map(async (permit) => {
+ // Exclude issue creator from commenter rewards
+ if (permit.userId !== creatorReward.userId) {
+ rewardByUser.push({
+ account: permit.account,
+ priceInEth: permit.priceInEth,
+ userId: permit.userId,
+ issueId: incentivesCalculation.issue.node_id,
+ type: [conversationRewards.title],
+ user: permit.user,
+ priceArray: [permit.priceInEth.toString()],
+ debug: permit.debug,
+ });
+ }
+ });
+ }
+
+ // PULL REQUEST REVIEWERS REWARD HANDLER
+ if (pullRequestReviewersReward.reward && pullRequestReviewersReward.reward.length > 0) {
+ pullRequestReviewersReward.reward.map(async (permit) => {
+ // Exclude issue creator from commenter rewards
+ if (permit.userId !== creatorReward.userId) {
+ rewardByUser.push({
+ account: permit.account,
+ priceInEth: permit.priceInEth,
+ userId: permit.userId,
+ issueId: incentivesCalculation.issue.node_id,
+ type: [pullRequestReviewersReward.title],
+ user: permit.user,
+ priceArray: [permit.priceInEth.toString()],
+ debug: permit.debug,
+ });
+ }
+ });
+ }
+
+ // CREATOR REWARD HANDLER
+ // Generate permit for user if its not the same id as assignee
+ if (creatorReward && creatorReward.reward && creatorReward.reward[0].account !== "0x") {
+ rewardByUser.push({
+ account: creatorReward.reward[0].account,
+ priceInEth: creatorReward.reward[0].priceInEth,
+ userId: creatorReward.userId,
+ issueId: incentivesCalculation.issue.node_id,
+ type: [creatorReward.title],
+ user: creatorReward.username,
+ priceArray: [creatorReward.reward[0].priceInEth.toString()],
+ debug: creatorReward.reward[0].debug,
+ });
+ } else if (creatorReward && creatorReward.reward && creatorReward.reward[0].account === "0x") {
+ logger.info(`Skipping to generate a permit url for missing account. fallback: ${creatorReward.fallbackReward}`);
+ }
+
+ // ASSIGNEE REWARD HANDLER
+ if (assigneeReward && assigneeReward.reward && assigneeReward.reward[0].account !== "0x") {
+ const permitComments = incentivesCalculation.comments.filter((content) => {
+ const permitUrlMatches = content.body.match(incentivesCalculation.claimUrlRegex);
+ if (!permitUrlMatches || permitUrlMatches.length < 2) return false;
+ else return true;
+ });
+
+ rewardByUser.push({
+ account: assigneeReward.reward[0].account,
+ priceInEth: assigneeReward.reward[0].priceInEth,
+ userId: assigneeReward.userId,
+ issueId: incentivesCalculation.issue.node_id,
+ type: title,
+ user: assigneeReward.username,
+ priceArray: [assigneeReward.reward[0].priceInEth.toString()],
+ debug: assigneeReward.reward[0].debug,
+ });
+
if (permitComments.length > 0) {
- logger.info(`Skip to generate a permit url because it has been already posted`);
- return `Permit generation skipped because it was already posted to this issue.`;
+ logger.info(`Skip to generate a permit url because it has been already posted.`);
+ return { error: `Permit generation disabled because it was already posted to this issue.` };
+ }
+
+ if (assigneeReward.reward[0].penaltyAmount.gt(0)) {
+ await removePenalty(
+ incentivesCalculation.assignee.login,
+ incentivesCalculation.payload.repository.full_name,
+ incentivesCalculation.paymentToken,
+ incentivesCalculation.networkId.toString(),
+ assigneeReward.reward[0].penaltyAmount
+ );
+ }
+ }
+
+ // MERGE ALL REWARDS
+ const rewards = rewardByUser.reduce((acc, curr) => {
+ const existing = acc.find((item) => item.userId === curr.userId);
+ if (existing) {
+ existing.priceInEth = existing.priceInEth.add(curr.priceInEth);
+ existing.priceArray = existing.priceArray.concat(curr.priceArray);
+ existing.type = existing.type.concat(curr.type);
+ } else {
+ acc.push(curr);
+ }
+ return acc;
+ }, [] as RewardByUser[]);
+
+ // sort rewards by price
+ rewards.sort((a, b) => {
+ return new Decimal(b.priceInEth).cmp(new Decimal(a.priceInEth));
+ });
+
+ // CREATE PERMIT URL FOR EACH USER
+ for (const reward of rewards) {
+ if (!reward.user || !reward.userId) {
+ logger.info(`Skipping to generate a permit url for missing user. fallback: ${reward.user}`);
+ continue;
+ }
+
+ const detailsValue = reward.priceArray
+ .map((price, i) => {
+ const separateTitle = reward.type[i]?.split("-");
+ if (!separateTitle) return { title: "", subtitle: "", value: "" };
+ return { title: separateTitle[0], subtitle: separateTitle[1], value: price };
+ })
+ // remove title if it's the same as the first one
+ .map((item, i, arr) => {
+ if (i === 0) return item;
+ if (item.title === arr[0].title) return { ...item, title: "" };
+ return item;
+ });
+
+ const { reason, value } = await getWalletMultiplier(reward.user, incentivesCalculation.id?.toString());
+
+ // if reason is not "", then add multiplier to detailsValue and multiply the price
+ if (reason) {
+ detailsValue.push({ title: "Multiplier", subtitle: "Amount", value: value.toString() });
+ detailsValue.push({ title: "", subtitle: "Reason", value: reason });
+
+ const multiplier = new Decimal(value);
+ const price = new Decimal(reward.priceInEth);
+ // add multiplier to the price
+ reward.priceInEth = price.mul(multiplier);
+ }
+
+ const { payoutUrl, txData } = await generatePermit2Signature(reward.account, reward.priceInEth, reward.issueId, reward.userId?.toString());
+
+ const price = `${reward.priceInEth} ${incentivesCalculation.tokenSymbol.toUpperCase()}`;
+
+ const comment = createDetailsTable(price, payoutUrl, reward.user, detailsValue, reward.debug);
+
+ await savePermitToDB(Number(reward.userId), txData);
+ permitComment += comment;
+
+ logger.info(`Skipping to generate a permit url for missing accounts. fallback: ${JSON.stringify(conversationRewards.fallbackReward)}`);
+ logger.info(`Skipping to generate a permit url for missing accounts. fallback: ${JSON.stringify(pullRequestReviewersReward.fallbackReward)}`);
}
- await deleteLabel(issueDetailed.priceLabel);
+
+ if (permitComment) await addCommentToIssue(permitComment.trim() + comments.promotionComment, issueNumber);
+
+ await deleteLabel(incentivesCalculation.issueDetailed.priceLabel);
await addLabelToIssue("Permitted");
- return comment;
+
+ return { error: "" };
};
diff --git a/src/handlers/payout/post.ts b/src/handlers/payout/post.ts
index c4d80b408..e1ef60e76 100644
--- a/src/handlers/payout/post.ts
+++ b/src/handlers/payout/post.ts
@@ -1,165 +1,342 @@
import { getWalletAddress } from "../../adapters/supabase";
-import { getBotConfig, getBotContext, getLogger } from "../../bindings";
-import { addCommentToIssue, generatePermit2Signature, getAllIssueComments, getIssueDescription, getTokenSymbol, parseComments } from "../../helpers";
-import { MarkdownItem, Payload, UserType, CommentElementPricing } from "../../types";
+import { getBotContext, getLogger } from "../../bindings";
+import { getAllIssueComments, getAllPullRequestReviews, getIssueDescription, parseComments } from "../../helpers";
+import { getLatestPullRequest, gitLinkedPrParser } from "../../helpers/parser";
+import { Incentives, MarkdownItem, Payload, UserType } from "../../types";
+import { RewardsResponse, commentParser } from "../comment";
+import Decimal from "decimal.js";
+import { bountyInfo } from "../wildcard";
+import { IncentivesCalculationResult } from "./action";
+import { BigNumber } from "ethers";
+
+export interface CreatorCommentResult {
+ title: string;
+ account?: string | undefined;
+ amountInETH?: Decimal | undefined;
+ userId?: string | undefined;
+ tokenSymbol?: string | undefined;
+ node_id?: string | undefined;
+ user?: string | undefined;
+}
const ItemsToExclude: string[] = [MarkdownItem.BlockQuote];
/**
* Incentivize the contributors based on their contribution.
* The default formula has been defined in https://github.com/ubiquity/ubiquibot/issues/272
*/
-export const incentivizeComments = async () => {
+export const calculateIssueConversationReward = async (calculateIncentives: IncentivesCalculationResult): Promise => {
+ const title = `Issue-Comments`;
const logger = getLogger();
- const {
- mode: { incentiveMode },
- price: { baseMultiplier, commentElementPricing },
- payout: { paymentToken, rpc },
- } = getBotConfig();
- if (!incentiveMode) {
- logger.info(`No incentive mode. skipping to process`);
- return;
- }
+
const context = getBotContext();
const payload = context.payload as Payload;
- const org = payload.organization?.login;
const issue = payload.issue;
- if (!issue || !org) {
- logger.info(`Incomplete payload. issue: ${issue}, org: ${org}`);
- return;
- }
+
const assignees = issue?.assignees ?? [];
const assignee = assignees.length > 0 ? assignees[0] : undefined;
if (!assignee) {
- logger.info("Skipping payment permit generation because `assignee` is `undefined`.");
- return;
+ logger.info("incentivizeComments: skipping payment permit generation because `assignee` is `undefined`.");
+ return { error: "incentivizeComments: skipping payment permit generation because `assignee` is `undefined`." };
}
- const issueComments = await getAllIssueComments(issue.number);
+ const issueComments = await getAllIssueComments(calculateIncentives.issue.number, "full");
logger.info(`Getting the issue comments done. comments: ${JSON.stringify(issueComments)}`);
- const issueCommentsByUser: Record = {};
+ const issueCommentsByUser: Record = {};
for (const issueComment of issueComments) {
const user = issueComment.user;
- if (user.type == UserType.Bot || user.login == assignee) continue;
- issueCommentsByUser[user.login].push(issueComment.body);
+ if (user.type == UserType.Bot || user.login == assignee.login) continue;
+ const commands = commentParser(issueComment.body);
+ if (commands.length > 0) {
+ logger.info(`Skipping to parse the comment because it contains commands. comment: ${JSON.stringify(issueComment)}`);
+ continue;
+ }
+ if (!issueComment.body_html) {
+ logger.info(`Skipping to parse the comment because body_html is undefined. comment: ${JSON.stringify(issueComment)}`);
+ continue;
+ }
+
+ // Store the comment along with user's login and node_id
+ if (!issueCommentsByUser[user.login]) {
+ issueCommentsByUser[user.login] = { id: user.id, comments: [] };
+ }
+ issueCommentsByUser[user.login].comments.push(issueComment.body_html);
}
- const tokenSymbol = await getTokenSymbol(paymentToken, rpc);
logger.info(`Filtering by the user type done. commentsByUser: ${JSON.stringify(issueCommentsByUser)}`);
- // The mapping between gh handle and comment with a permit url
- const reward: Record = {};
-
// The mapping between gh handle and amount in ETH
- const fallbackReward: Record = {};
- let comment = "";
+ const fallbackReward: Record = {};
+
+ // array of awaiting permits to generate
+ const reward: {
+ account: string;
+ priceInEth: Decimal;
+ userId: number;
+ user: string;
+ penaltyAmount: BigNumber;
+ debug: Record;
+ }[] = [];
+
for (const user of Object.keys(issueCommentsByUser)) {
- const comments = issueCommentsByUser[user];
- const commentsByNode = await parseComments(comments, ItemsToExclude);
- const rewardValue = calculateRewardValue(commentsByNode, commentElementPricing);
- logger.debug(`Comment parsed for the user: ${user}. comments: ${JSON.stringify(commentsByNode)}, sum: ${rewardValue}`);
+ const commentsByUser = issueCommentsByUser[user];
+ const commentsByNode = await parseComments(commentsByUser.comments, ItemsToExclude);
+ const rewardValue = calculateRewardValue(commentsByNode, calculateIncentives.incentives);
+ if (rewardValue.sum.equals(0)) {
+ logger.info(`Skipping to generate a permit url because the reward value is 0. user: ${user}`);
+ continue;
+ }
+ logger.debug(`Comment parsed for the user: ${user}. comments: ${JSON.stringify(commentsByNode)}, sum: ${rewardValue.sum}`);
const account = await getWalletAddress(user);
- const amountInETH = ((rewardValue * baseMultiplier) / 1000).toString();
+ const priceInEth = rewardValue.sum.mul(calculateIncentives.baseMultiplier);
+ if (priceInEth.gt(calculateIncentives.paymentPermitMaxPrice)) {
+ logger.info(`Skipping comment reward for user ${user} because reward is higher than payment permit max price`);
+ continue;
+ }
if (account) {
- const payoutUrl = await generatePermit2Signature(account, amountInETH, issue.node_id);
- comment = `${comment}### [ **${user}: [ CLAIM ${amountInETH} ${tokenSymbol.toUpperCase()} ]** ](${payoutUrl})\n`;
- reward[user] = payoutUrl;
+ reward.push({ account, priceInEth, userId: commentsByUser.id, user, penaltyAmount: BigNumber.from(0), debug: rewardValue.sumByType });
} else {
- fallbackReward[user] = amountInETH;
+ fallbackReward[user] = priceInEth;
}
}
- logger.info(`Permit url generated for contributors. reward: ${JSON.stringify(reward)}`);
- logger.info(`Skipping to generate a permit url for missing accounts. fallback: ${JSON.stringify(fallbackReward)}`);
-
- await addCommentToIssue(comment, issue.number);
+ return { error: "", title, reward, fallbackReward };
};
-export const incentivizeCreatorComment = async () => {
+export const calculateIssueCreatorReward = async (incentivesCalculation: IncentivesCalculationResult): Promise => {
+ const title = `Issue-Creation`;
const logger = getLogger();
- const {
- mode: { incentiveMode },
- price: { commentElementPricing, issueCreatorMultiplier },
- payout: { paymentToken, rpc },
- } = getBotConfig();
- if (!incentiveMode) {
- logger.info(`No incentive mode. skipping to process`);
- return;
- }
- const context = getBotContext();
- const payload = context.payload as Payload;
- const org = payload.organization?.login;
- const issue = payload.issue;
- if (!issue || !org) {
- logger.info(`Incomplete payload. issue: ${issue}, org: ${org}`);
- return;
+
+ const issueDetailed = bountyInfo(incentivesCalculation.issue);
+ if (!issueDetailed.isBounty) {
+ logger.info(`incentivizeCreatorComment: its not a bounty`);
+ return { error: `incentivizeCreatorComment: its not a bounty` };
}
- const assignees = issue?.assignees ?? [];
+
+ const assignees = incentivesCalculation.issue.assignees ?? [];
const assignee = assignees.length > 0 ? assignees[0] : undefined;
if (!assignee) {
- logger.info("Skipping payment permit generation because `assignee` is `undefined`.");
- return;
+ logger.info("incentivizeCreatorComment: skipping payment permit generation because `assignee` is `undefined`.");
+ return { error: "incentivizeCreatorComment: skipping payment permit generation because `assignee` is `undefined`." };
}
- const description = await getIssueDescription(issue.number);
+ const description = await getIssueDescription(incentivesCalculation.issue.number, "html");
+ if (!description) {
+ logger.info(`Skipping to generate a permit url because issue description is empty. description: ${description}`);
+ return { error: `Skipping to generate a permit url because issue description is empty. description: ${description}` };
+ }
logger.info(`Getting the issue description done. description: ${description}`);
- const creator = issue.user;
- if (creator?.type === UserType.Bot || creator?.login === issue?.assignee) {
+ const creator = incentivesCalculation.issue.user;
+ if (creator.type === UserType.Bot || creator.login === incentivesCalculation.issue.assignee) {
logger.info("Issue creator assigneed himself or Bot created this issue.");
- return;
+ return { error: "Issue creator assigneed himself or Bot created this issue." };
+ }
+
+ const result = await generatePermitForComments(
+ creator.login,
+ [description],
+ incentivesCalculation.issueCreatorMultiplier,
+ incentivesCalculation.incentives,
+ incentivesCalculation.paymentPermitMaxPrice
+ );
+
+ if (!result || !result.account || !result.amountInETH) {
+ throw new Error("Failed to generate permit for issue creator because of missing account or amountInETH");
+ }
+
+ return {
+ error: "",
+ title,
+ userId: creator.id,
+ username: creator.login,
+ reward: [
+ {
+ priceInEth: result?.amountInETH ?? new Decimal(0),
+ account: result?.account,
+ userId: creator.id,
+ user: "",
+ penaltyAmount: BigNumber.from(0),
+ debug: {},
+ },
+ ],
+ };
+};
+
+export const calculatePullRequestReviewsReward = async (incentivesCalculation: IncentivesCalculationResult): Promise => {
+ const logger = getLogger();
+ const context = getBotContext();
+ const title = "Review-Reviewer";
+
+ const linkedPullRequest = await gitLinkedPrParser({
+ owner: incentivesCalculation.payload.repository.owner.login,
+ repo: incentivesCalculation.payload.repository.name,
+ issue_number: incentivesCalculation.issue.number,
+ });
+
+ const latestLinkedPullRequest = await getLatestPullRequest(linkedPullRequest);
+
+ if (!latestLinkedPullRequest) {
+ logger.debug(`calculatePullRequestReviewsReward: No linked pull requests found`);
+ return { error: `calculatePullRequestReviewsReward: No linked pull requests found` };
}
- const tokenSymbol = await getTokenSymbol(paymentToken, rpc);
- const result = await generatePermitForComments(creator?.login, [description], issueCreatorMultiplier, commentElementPricing, tokenSymbol, issue.node_id);
+ const assignees = incentivesCalculation.issue?.assignees ?? [];
+ const assignee = assignees.length > 0 ? assignees[0] : undefined;
+ if (!assignee) {
+ logger.info("calculatePullRequestReviewsReward: skipping payment permit generation because `assignee` is `undefined`.");
+ return { error: "calculatePullRequestReviewsReward: skipping payment permit generation because `assignee` is `undefined`." };
+ }
- if (result.payoutUrl) {
- logger.info(`Permit url generated for creator. reward: ${result.payoutUrl}`);
- await addCommentToIssue(result.comment, issue.number);
+ const prReviews = await getAllPullRequestReviews(context, latestLinkedPullRequest.number, "full");
+ const prComments = await getAllIssueComments(latestLinkedPullRequest.number, "full");
+ logger.info(`Getting the PR reviews done. comments: ${JSON.stringify(prReviews)}`);
+ const prReviewsByUser: Record = {};
+ for (const review of prReviews) {
+ const user = review.user;
+ if (!user) continue;
+ if (user.type == UserType.Bot || user.login == assignee) continue;
+ if (!review.body_html) {
+ logger.info(`calculatePullRequestReviewsReward: Skipping to parse the comment because body_html is undefined. comment: ${JSON.stringify(review)}`);
+ continue;
+ }
+ if (!prReviewsByUser[user.login]) {
+ prReviewsByUser[user.login] = { id: user.id, comments: [] };
+ }
+ prReviewsByUser[user.login].comments.push(review.body_html);
}
- if (result.amountInETH) {
- logger.info(`Skipping to generate a permit url for missing account. fallback: ${result.amountInETH}`);
+
+ for (const comment of prComments) {
+ const user = comment.user;
+ if (!user) continue;
+ if (user.type == UserType.Bot || user.login == assignee) continue;
+ if (!comment.body_html) {
+ logger.info(`calculatePullRequestReviewsReward: Skipping to parse the comment because body_html is undefined. comment: ${JSON.stringify(comment)}`);
+ continue;
+ }
+ if (!prReviewsByUser[user.login]) {
+ prReviewsByUser[user.login] = { id: user.id, comments: [] };
+ }
+ prReviewsByUser[user.login].comments.push(comment.body_html);
+ }
+
+ logger.info(`calculatePullRequestReviewsReward: Filtering by the user type done. commentsByUser: ${JSON.stringify(prReviewsByUser)}`);
+
+ // array of awaiting permits to generate
+ const reward: {
+ account: string;
+ priceInEth: Decimal;
+ userId: number;
+ user: string;
+ penaltyAmount: BigNumber;
+ debug: Record;
+ }[] = [];
+
+ // The mapping between gh handle and amount in ETH
+ const fallbackReward: Record = {};
+
+ for (const user of Object.keys(prReviewsByUser)) {
+ const commentByUser = prReviewsByUser[user];
+ const commentsByNode = await parseComments(commentByUser.comments, ItemsToExclude);
+ const rewardValue = calculateRewardValue(commentsByNode, incentivesCalculation.incentives);
+ if (rewardValue.sum.equals(0)) {
+ logger.info(`calculatePullRequestReviewsReward: Skipping to generate a permit url because the reward value is 0. user: ${user}`);
+ continue;
+ }
+ logger.info(
+ `calculatePullRequestReviewsReward: Comment parsed for the user: ${user}. comments: ${JSON.stringify(commentsByNode)}, sum: ${rewardValue.sum}`
+ );
+ const account = await getWalletAddress(user);
+ const priceInEth = rewardValue.sum.mul(incentivesCalculation.baseMultiplier);
+ if (priceInEth.gt(incentivesCalculation.paymentPermitMaxPrice)) {
+ logger.info(`calculatePullRequestReviewsReward: Skipping comment reward for user ${user} because reward is higher than payment permit max price`);
+ continue;
+ }
+
+ if (account) {
+ reward.push({ account, priceInEth, userId: commentByUser.id, user, penaltyAmount: BigNumber.from(0), debug: rewardValue.sumByType });
+ } else {
+ fallbackReward[user] = priceInEth;
+ }
}
+
+ logger.info(`calculatePullRequestReviewsReward: Permit url generated for pull request reviewers. reward: ${JSON.stringify(reward)}`);
+ logger.info(`calculatePullRequestReviewsReward: Skipping to generate a permit url for missing accounts. fallback: ${JSON.stringify(fallbackReward)}`);
+
+ return { error: "", title, reward, fallbackReward };
};
const generatePermitForComments = async (
user: string,
comments: string[],
multiplier: number,
- commentElementPricing: Record,
- tokenSymbol: string,
- node_id: string
-): Promise<{ comment: string; payoutUrl?: string; amountInETH?: string }> => {
+ incentives: Incentives,
+ paymentPermitMaxPrice: number
+): Promise => {
const logger = getLogger();
const commentsByNode = await parseComments(comments, ItemsToExclude);
- const rewardValue = calculateRewardValue(commentsByNode, commentElementPricing);
- logger.debug(`Comment parsed for the user: ${user}. comments: ${JSON.stringify(commentsByNode)}, sum: ${rewardValue}`);
+ const rewardValue = calculateRewardValue(commentsByNode, incentives);
+ if (rewardValue.sum.equals(0)) {
+ logger.info(`No reward for the user: ${user}. comments: ${JSON.stringify(commentsByNode)}, sum: ${rewardValue}`);
+ return;
+ }
+ logger.debug(`Comment parsed for the user: ${user}. comments: ${JSON.stringify(commentsByNode)}, sum: ${rewardValue.sum}`);
const account = await getWalletAddress(user);
- const amountInETH = ((rewardValue * multiplier) / 1000).toString();
- let comment = "";
+ const amountInETH = rewardValue.sum.mul(multiplier);
+ if (amountInETH.gt(paymentPermitMaxPrice)) {
+ logger.info(`Skipping issue creator reward for user ${user} because reward is higher than payment permit max price`);
+ return;
+ }
if (account) {
- const payoutUrl = await generatePermit2Signature(account, amountInETH, node_id);
- comment = `${comment}### [ **${user}: [ CLAIM ${amountInETH} ${tokenSymbol.toUpperCase()} ]** ](${payoutUrl})\n`;
- return { comment, payoutUrl };
+ return { account, amountInETH };
} else {
- return { comment, amountInETH };
+ return { account: "0x", amountInETH: new Decimal(0) };
}
};
/**
* @dev Calculates the reward values for a given comments. We'll improve the formula whenever we get the better one.
*
* @param comments - The comments to calculate the reward for
- * @param commentElementPricing - The basic price table for reward calculation
+ * @param incentives - The basic price table for reward calculation
* @returns - The reward value
*/
-const calculateRewardValue = (comments: Record, commentElementPricing: CommentElementPricing): number => {
- let sum = 0;
+const calculateRewardValue = (
+ comments: Record,
+ incentives: Incentives
+): { sum: Decimal; sumByType: Record } => {
+ let sum = new Decimal(0);
+ const sumByType: Record = {};
+
for (const key of Object.keys(comments)) {
- const rewardValue = commentElementPricing[key];
const value = comments[key];
- if (key == MarkdownItem.Text || key == MarkdownItem.Paragraph) {
- sum += value.length * rewardValue;
+
+ // Initialize the sum for this key if it doesn't exist
+ if (!sumByType[key]) {
+ sumByType[key] = {
+ count: 0,
+ reward: new Decimal(0),
+ };
+ }
+
+ // if it's a text node calculate word count and multiply with the reward value
+ if (key == "#text") {
+ if (!incentives.comment.totals.word) {
+ continue;
+ }
+ const wordReward = new Decimal(incentives.comment.totals.word);
+ const wordCount = value.map((str) => str.trim().split(" ").length).reduce((totalWords, wordCount) => totalWords + wordCount, 0);
+ const reward = wordReward.mul(wordCount);
+ sumByType[key].count += wordCount;
+ sumByType[key].reward = wordReward;
+ sum = sum.add(reward);
} else {
- sum += rewardValue;
+ if (!incentives.comment.elements[key]) {
+ continue;
+ }
+ const rewardValue = new Decimal(incentives.comment.elements[key]);
+ const reward = rewardValue.mul(value.length);
+ sumByType[key].count += value.length;
+ sumByType[key].reward = rewardValue;
+ sum = sum.add(reward);
}
}
- return sum;
+ return { sum, sumByType };
};
diff --git a/src/handlers/pricing/action.ts b/src/handlers/pricing/action.ts
index e32b899bc..7e9673c6f 100644
--- a/src/handlers/pricing/action.ts
+++ b/src/handlers/pricing/action.ts
@@ -1,6 +1,7 @@
import { getBotConfig, getBotContext, getLogger } from "../../bindings";
-import { addLabelToIssue, clearAllPriceLabelsOnIssue, createLabel, getLabel } from "../../helpers";
-import { Payload } from "../../types";
+import { GLOBAL_STRINGS } from "../../configs";
+import { addCommentToIssue, addLabelToIssue, clearAllPriceLabelsOnIssue, createLabel, getLabel, calculateWeight, getAllLabeledEvents } from "../../helpers";
+import { Payload, UserType } from "../../types";
import { handleLabelsAccess } from "../access";
import { getTargetPriceLabel } from "../shared";
@@ -11,29 +12,67 @@ export const pricingLabelLogic = async (): Promise => {
const payload = context.payload as Payload;
if (!payload.issue) return;
const labels = payload.issue.labels;
-
+ const labelNames = labels.map((i) => i.name);
+ logger.info(`Checking if the issue is a parent issue.`);
+ if (payload.issue.body && isParentIssue(payload.issue.body)) {
+ logger.error("Identified as parent issue. Disabling price label.");
+ const issuePrices = labels.filter((label) => label.name.toString().startsWith("Price:"));
+ if (issuePrices.length) {
+ await addCommentToIssue(GLOBAL_STRINGS.skipPriceLabelGenerationComment, payload.issue.number);
+ await clearAllPriceLabelsOnIssue();
+ }
+ return;
+ }
const valid = await handleLabelsAccess();
- if (!valid) {
+ if (!valid && config.accessControl.label) {
return;
}
+ const { assistivePricing } = config.mode;
const timeLabels = config.price.timeLabels.filter((item) => labels.map((i) => i.name).includes(item.name));
const priorityLabels = config.price.priorityLabels.filter((item) => labels.map((i) => i.name).includes(item.name));
- const minTimeLabel = timeLabels.length > 0 ? timeLabels.reduce((a, b) => (a.weight < b.weight ? a : b)).name : undefined;
- const minPriorityLabel = priorityLabels.length > 0 ? priorityLabels.reduce((a, b) => (a.weight < b.weight ? a : b)).name : undefined;
+ const minTimeLabel = timeLabels.length > 0 ? timeLabels.reduce((a, b) => (calculateWeight(a) < calculateWeight(b) ? a : b)).name : undefined;
+ const minPriorityLabel = priorityLabels.length > 0 ? priorityLabels.reduce((a, b) => (calculateWeight(a) < calculateWeight(b) ? a : b)).name : undefined;
const targetPriceLabel = getTargetPriceLabel(minTimeLabel, minPriorityLabel);
+
if (targetPriceLabel) {
- if (labels.map((i) => i.name).includes(targetPriceLabel)) {
- logger.info(`Skipping... already exists`);
+ const _targetPriceLabel = labelNames.find((name) => name.includes("Price") && name.includes(targetPriceLabel));
+
+ if (_targetPriceLabel) {
+ // get all issue events of type "labeled" and the event label includes Price
+ let labeledEvents = await getAllLabeledEvents();
+ if (!labeledEvents) return;
+
+ labeledEvents = labeledEvents.filter((event) => event.label?.name.includes("Price"));
+ if (!labeledEvents.length) return;
+
+ // check if the latest price label has been added by a user
+ if (labeledEvents[labeledEvents.length - 1].actor?.type == UserType.User) {
+ logger.info(`Skipping... already exists`);
+ } else {
+ // add price label to issue becuase wrong price has been added by bot
+ logger.info(`Adding price label to issue`);
+ await clearAllPriceLabelsOnIssue();
+
+ const exist = await getLabel(targetPriceLabel);
+
+ if (assistivePricing && !exist) {
+ logger.info(`${targetPriceLabel} doesn't exist on the repo, creating...`);
+ await createLabel(targetPriceLabel, "price");
+ }
+ await addLabelToIssue(targetPriceLabel);
+ }
} else {
+ // add price if there is none
logger.info(`Adding price label to issue`);
await clearAllPriceLabelsOnIssue();
const exist = await getLabel(targetPriceLabel);
- if (!exist) {
+
+ if (assistivePricing && !exist) {
logger.info(`${targetPriceLabel} doesn't exist on the repo, creating...`);
await createLabel(targetPriceLabel, "price");
}
@@ -44,3 +83,8 @@ export const pricingLabelLogic = async (): Promise => {
logger.info(`Skipping action...`);
}
};
+
+export const isParentIssue = (body: string) => {
+ const parentPattern = /-\s+\[( |x)\]\s+#\d+/;
+ return body.match(parentPattern);
+};
diff --git a/src/handlers/pricing/pre.ts b/src/handlers/pricing/pre.ts
index 29703bda7..4d048d1ca 100644
--- a/src/handlers/pricing/pre.ts
+++ b/src/handlers/pricing/pre.ts
@@ -1,5 +1,5 @@
import { getBotConfig, getLogger } from "../../bindings";
-import { createLabel, listLabelsForRepo } from "../../helpers";
+import { calculateWeight, createLabel, listLabelsForRepo } from "../../helpers";
import { calculateBountyPrice } from "../shared";
/**
@@ -10,12 +10,19 @@ export const validatePriceLabels = async (): Promise => {
const config = getBotConfig();
const logger = getLogger();
+ const { assistivePricing } = config.mode;
+
+ if (!assistivePricing) {
+ logger.info(`Assistive Pricing is disabled`);
+ return;
+ }
+
const timeLabels = config.price.timeLabels.map((i) => i.name);
const priorityLabels = config.price.priorityLabels.map((i) => i.name);
const aiLabels: string[] = [];
for (const timeLabel of config.price.timeLabels) {
for (const priorityLabel of config.price.priorityLabels) {
- const targetPrice = calculateBountyPrice(timeLabel.weight, priorityLabel.weight, config.price.baseMultiplier);
+ const targetPrice = calculateBountyPrice(calculateWeight(timeLabel), calculateWeight(priorityLabel), config.price.baseMultiplier);
const targetPriceLabel = `Price: ${targetPrice} USD`;
aiLabels.push(targetPriceLabel);
}
diff --git a/src/handlers/processors.ts b/src/handlers/processors.ts
index 340c091f3..17bec49c7 100644
--- a/src/handlers/processors.ts
+++ b/src/handlers/processors.ts
@@ -3,16 +3,22 @@ import { closePullRequestForAnIssue, commentWithAssignMessage } from "./assign";
import { pricingLabelLogic, validatePriceLabels } from "./pricing";
import { checkBountiesToUnassign, collectAnalytics, checkWeeklyUpdate } from "./wildcard";
import { nullHandler } from "./shared";
-import { handleComment, issueClosedCallback, issueCreatedCallback } from "./comment";
+import { handleComment, issueClosedCallback, issueCreatedCallback, issueReopenedCallback } from "./comment";
import { checkPullRequests } from "./assign/auto";
import { createDevPoolPR } from "./pull-request";
-import { runOnPush } from "./push";
-import { incentivizeComments, incentivizeCreatorComment } from "./payout";
+import { runOnPush, validateConfigChange } from "./push";
+import { findDuplicateOne } from "./issue";
+import { watchLabelChange } from "./label";
export const processors: Record = {
[GithubEvent.ISSUES_OPENED]: {
pre: [nullHandler],
- action: [issueCreatedCallback],
+ action: [findDuplicateOne, issueCreatedCallback],
+ post: [nullHandler],
+ },
+ [GithubEvent.ISSUES_REOPENED]: {
+ pre: [nullHandler],
+ action: [issueReopenedCallback],
post: [nullHandler],
},
[GithubEvent.ISSUES_LABELED]: {
@@ -48,7 +54,7 @@ export const processors: Record = {
[GithubEvent.ISSUES_CLOSED]: {
pre: [nullHandler],
action: [issueClosedCallback],
- post: [incentivizeCreatorComment, incentivizeComments],
+ post: [nullHandler],
},
[GithubEvent.PULL_REQUEST_OPENED]: {
pre: [nullHandler],
@@ -62,7 +68,12 @@ export const processors: Record = {
},
[GithubEvent.PUSH_EVENT]: {
pre: [nullHandler],
- action: [runOnPush],
+ action: [validateConfigChange, runOnPush],
+ post: [nullHandler],
+ },
+ [GithubEvent.LABEL_EDITED]: {
+ pre: [nullHandler],
+ action: [watchLabelChange],
post: [nullHandler],
},
};
diff --git a/src/handlers/pull-request/create-devpool-pr.ts b/src/handlers/pull-request/create-devpool-pr.ts
index 75689171f..08046bfbc 100644
--- a/src/handlers/pull-request/create-devpool-pr.ts
+++ b/src/handlers/pull-request/create-devpool-pr.ts
@@ -8,7 +8,7 @@ export const createDevPoolPR = async () => {
const payload = context.payload as Payload;
const devPoolOwner = "ubiquity";
- const devPoolRepo = "devpool";
+ const devPoolRepo = "devpool-directory";
if (!payload.repositories_added) {
return;
diff --git a/src/handlers/push/index.ts b/src/handlers/push/index.ts
index 4cd4775ae..340143439 100644
--- a/src/handlers/push/index.ts
+++ b/src/handlers/push/index.ts
@@ -1,6 +1,9 @@
import { getBotContext, getLogger } from "../../bindings";
-import { CommitsPayload, PushPayload } from "../../types";
+import { createCommitComment, getFileContent } from "../../helpers";
+import { CommitsPayload, PushPayload, WideConfigSchema } from "../../types";
+import { parseYAML } from "../../utils/private";
import { updateBaseRate } from "./update-base";
+import { validate } from "../../utils/ajv";
const ZERO_SHA = "0000000000000000000000000000000000000000";
const BASE_RATE_FILE = ".github/ubiquibot-config.yml";
@@ -45,3 +48,49 @@ export const runOnPush = async () => {
await updateBaseRate(context, payload, BASE_RATE_FILE);
}
};
+
+export const validateConfigChange = async () => {
+ const logger = getLogger();
+
+ const context = getBotContext();
+ const payload = context.payload as PushPayload;
+
+ if (!payload.ref.startsWith("refs/heads/")) {
+ logger.debug("Skipping push events, not a branch");
+ return;
+ }
+
+ const changes = getCommitChanges(payload.commits);
+
+ // skip if empty
+ if (changes && changes.length === 0) {
+ logger.debug("Skipping push events, file change empty");
+ return;
+ }
+
+ // check for modified or added files and check for specified file
+ if (changes.includes(BASE_RATE_FILE)) {
+ const commitSha = payload.commits.filter((commit) => commit.modified.includes(BASE_RATE_FILE) || commit.added.includes(BASE_RATE_FILE)).reverse()[0]?.id;
+ if (!commitSha) {
+ logger.debug("Skipping push events, commit sha not found");
+ return;
+ }
+
+ const configFileContent = await getFileContent(
+ payload.repository.owner.login,
+ payload.repository.name,
+ payload.ref.split("refs/heads/")[1],
+ BASE_RATE_FILE,
+ commitSha
+ );
+
+ if (configFileContent) {
+ const decodedConfig = Buffer.from(configFileContent, "base64").toString();
+ const config = parseYAML(decodedConfig);
+ const { valid, error } = validate(WideConfigSchema, config);
+ if (!valid) {
+ await createCommitComment(`@${payload.sender.login} Config validation failed! ${error}`, commitSha, BASE_RATE_FILE);
+ }
+ }
+ }
+};
diff --git a/src/handlers/push/update-base.ts b/src/handlers/push/update-base.ts
index 6f10418de..da67c8e6c 100644
--- a/src/handlers/push/update-base.ts
+++ b/src/handlers/push/update-base.ts
@@ -21,12 +21,12 @@ export const updateBaseRate = async (context: Context, payload: PushPayload, fil
const previousContent = Buffer.from(preFileContent, "base64").toString();
const previousConfig = await parseYAML(previousContent);
- if (!previousConfig || !previousConfig["base-multiplier"]) {
+ if (!previousConfig || !previousConfig["priceMultiplier"]) {
logger.debug("No multiplier found in file object");
return;
}
- const previousBaseRate = previousConfig["base-multiplier"];
+ const previousBaseRate = previousConfig["priceMultiplier"];
// fetch all labels
const repoLabels = await listLabelsForRepo();
diff --git a/src/handlers/shared/pricing.ts b/src/handlers/shared/pricing.ts
index f422ca84c..0a543bc7c 100644
--- a/src/handlers/shared/pricing.ts
+++ b/src/handlers/shared/pricing.ts
@@ -1,10 +1,11 @@
import { getBotConfig } from "../../bindings";
+import { calculateWeight } from "../../helpers";
export const calculateBountyPrice = (timeValue: number, priorityValue: number, baseValue?: number): number => {
const botConfig = getBotConfig();
const base = baseValue ?? botConfig.price.baseMultiplier;
const priority = priorityValue / 10; // floats cause bad math
- const price = base * timeValue * priority;
+ const price = 1000 * base * timeValue * priority;
return price;
};
@@ -12,8 +13,8 @@ export const getTargetPriceLabel = (timeLabel: string | undefined, priorityLabel
const botConfig = getBotConfig();
let targetPriceLabel: string | undefined = undefined;
if (timeLabel && priorityLabel) {
- const timeWeight = botConfig.price.timeLabels.find((item) => item.name === timeLabel)?.weight;
- const priorityWeight = botConfig.price.priorityLabels.find((item) => item.name === priorityLabel)?.weight;
+ const timeWeight = calculateWeight(botConfig.price.timeLabels.find((item) => item.name === timeLabel));
+ const priorityWeight = calculateWeight(botConfig.price.priorityLabels.find((item) => item.name === priorityLabel));
if (timeWeight && priorityWeight) {
const bountyPrice = calculateBountyPrice(timeWeight, priorityWeight);
targetPriceLabel = `Price: ${bountyPrice} USD`;
diff --git a/src/handlers/wildcard/analytics.ts b/src/handlers/wildcard/analytics.ts
index e88e91898..df877f070 100644
--- a/src/handlers/wildcard/analytics.ts
+++ b/src/handlers/wildcard/analytics.ts
@@ -1,8 +1,7 @@
import { getMaxIssueNumber, upsertIssue, upsertUser } from "../../adapters/supabase";
import { getBotConfig, getLogger } from "../../bindings";
-import { listIssuesForRepo, getUser } from "../../helpers";
+import { listIssuesForRepo, getUser, calculateWeight } from "../../helpers";
import { Issue, IssueType, User, UserProfile } from "../../types";
-import { getTargetPriceLabel } from "../shared";
/**
* Checks the issue whether it's a bounty for hunters or an issue for not
@@ -19,15 +18,16 @@ export const bountyInfo = (
} => {
const config = getBotConfig();
const labels = issue.labels;
+
const timeLabels = config.price.timeLabels.filter((item) => labels.map((i) => i.name).includes(item.name));
const priorityLabels = config.price.priorityLabels.filter((item) => labels.map((i) => i.name).includes(item.name));
const isBounty = timeLabels.length > 0 && priorityLabels.length > 0;
- const minTimeLabel = timeLabels.length > 0 ? timeLabels.reduce((a, b) => (a.weight < b.weight ? a : b)).name : undefined;
- const minPriorityLabel = priorityLabels.length > 0 ? priorityLabels.reduce((a, b) => (a.weight < b.weight ? a : b)).name : undefined;
+ const minTimeLabel = timeLabels.length > 0 ? timeLabels.reduce((a, b) => (calculateWeight(a) < calculateWeight(b) ? a : b)).name : undefined;
+ const minPriorityLabel = priorityLabels.length > 0 ? priorityLabels.reduce((a, b) => (calculateWeight(a) < calculateWeight(b) ? a : b)).name : undefined;
- const priceLabel = getTargetPriceLabel(minTimeLabel, minPriorityLabel);
+ const priceLabel = labels.find((label) => label.name.includes("Price"))?.name;
return {
isBounty,
@@ -43,10 +43,10 @@ export const bountyInfo = (
export const collectAnalytics = async (): Promise => {
const logger = getLogger();
const {
- mode: { analyticsMode },
+ mode: { disableAnalytics },
} = getBotConfig();
- if (!analyticsMode) {
- logger.info(`Skipping to collect analytics, reason: mode=${analyticsMode}`);
+ if (disableAnalytics) {
+ logger.info(`Skipping to collect analytics, reason: mode=${disableAnalytics}`);
return;
}
logger.info("Collecting analytics information...");
@@ -83,26 +83,28 @@ export const collectAnalytics = async (): Promise => {
.toString()}`
);
- await Promise.all(userProfilesToUpsert.map(async (i) => upsertUser(i)));
+ await Promise.all(userProfilesToUpsert.map((i) => upsertUser(i)));
// No need to update the record for the bounties already closed
const bountiesToUpsert = bounties.filter((bounty) => (bounty.state === IssueType.CLOSED ? bounty.number > maximumIssueNumber : true));
logger.info(`Upserting bounties: ${bountiesToUpsert.map((i) => i.title).toString()}`);
await Promise.all(
- bountiesToUpsert.map(async (i) => {
+ bountiesToUpsert.map((i) => {
const additions = bountyInfo(i as Issue);
if (additions.timelabel && additions.priorityLabel && additions.priceLabel)
- await upsertIssue(i as Issue, {
+ return upsertIssue(i as Issue, {
labels: {
timeline: additions.timelabel,
priority: additions.priorityLabel,
price: additions.priceLabel,
},
});
+ return undefined;
})
);
if (issues.length < perPage) fetchDone = true;
else curPage++;
}
+ logger.info("Collecting analytics finished...");
};
diff --git a/src/handlers/wildcard/unassign.ts b/src/handlers/wildcard/unassign.ts
index 167d7819e..ec057ce73 100644
--- a/src/handlers/wildcard/unassign.ts
+++ b/src/handlers/wildcard/unassign.ts
@@ -1,15 +1,16 @@
-import { closePullRequestForAnIssue } from "..";
-import { getBotConfig, getLogger } from "../../bindings";
+import { getBotConfig, getBotContext, getLogger } from "../../bindings";
import { GLOBAL_STRINGS } from "../../configs/strings";
import {
addCommentToIssue,
- getCommentsOfIssue,
+ getAllIssueComments,
getCommitsOnPullRequest,
getOpenedPullRequestsForAnIssue,
- listIssuesForRepo,
+ getReviewRequests,
+ listAllIssuesForRepo,
removeAssignees,
} from "../../helpers";
-import { Comment, Issue, IssueType } from "../../types";
+import { Comment, Issue, IssueType, Payload, UserType } from "../../types";
+import { deadLinePrefix } from "../shared";
/**
* @dev Check out the bounties which haven't been completed within the initial timeline
@@ -21,7 +22,7 @@ export const checkBountiesToUnassign = async () => {
// List all the issues in the repository. It may include `pull_request`
// because GitHub's REST API v3 considers every pull request an issue
- const issues_opened = await listIssuesForRepo(IssueType.OPEN);
+ const issues_opened = await listAllIssuesForRepo(IssueType.OPEN);
const assigned_issues = issues_opened.filter((issue) => issue.assignee);
@@ -31,6 +32,8 @@ export const checkBountiesToUnassign = async () => {
};
const checkBountyToUnassign = async (issue: Issue): Promise => {
+ const context = getBotContext();
+ const payload = context.payload as Payload;
const logger = getLogger();
const {
unassign: { followUpTime, disqualifyTime },
@@ -38,26 +41,34 @@ const checkBountyToUnassign = async (issue: Issue): Promise => {
logger.info(`Checking the bounty to unassign, issue_number: ${issue.number}`);
const { unassignComment, askUpdate } = GLOBAL_STRINGS;
const assignees = issue.assignees.map((i) => i.login);
- const comments = await getCommentsOfIssue(issue.number);
+ const comments = await getAllIssueComments(issue.number);
if (!comments || comments.length == 0) return false;
const askUpdateComments = comments
.filter((comment: Comment) => comment.body.includes(askUpdate))
.sort((a: Comment, b: Comment) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
+
const lastAskTime = askUpdateComments.length > 0 ? new Date(askUpdateComments[0].created_at).getTime() : new Date(issue.created_at).getTime();
const curTimestamp = new Date().getTime();
- const lastActivity = await lastActivityTime(issue);
+ const lastActivity = await lastActivityTime(issue, comments);
const passedDuration = curTimestamp - lastActivity.getTime();
+ const pullRequest = await getOpenedPullRequestsForAnIssue(issue.number, issue.assignee.login);
+
+ if (pullRequest.length > 0) {
+ const reviewRequests = await getReviewRequests(context, pullRequest[0].number, payload.repository.owner.login, payload.repository.name);
+ if (!reviewRequests || reviewRequests.users?.length > 0) {
+ return false;
+ }
+ }
if (passedDuration >= disqualifyTime || passedDuration >= followUpTime) {
if (passedDuration >= disqualifyTime) {
logger.info(
`Unassigning... lastActivityTime: ${lastActivity.getTime()}, curTime: ${curTimestamp}, passedDuration: ${passedDuration}, followUpTime: ${followUpTime}, disqualifyTime: ${disqualifyTime}`
);
- await closePullRequestForAnIssue();
// remove assignees from the issue
await removeAssignees(issue.number, assignees);
- await addCommentToIssue(`${unassignComment} \nLast activity time: ${lastActivity}`, issue.number);
+ await addCommentToIssue(`@${assignees[0]} - ${unassignComment} \nLast activity time: ${lastActivity}`, issue.number);
return true;
} else if (passedDuration >= followUpTime) {
@@ -69,25 +80,31 @@ const checkBountyToUnassign = async (issue: Issue): Promise => {
logger.info(
`Skipping posting an update message cause its been already asked, lastAskTime: ${lastAskTime}, lastActivityTime: ${lastActivity.getTime()}`
);
- } else
+ } else {
await addCommentToIssue(
- `${askUpdate} @${assignees[0]}? If you would like to release the bounty back to the DevPool, please comment \`/unassign\` \nLast activity time: ${lastActivity}`,
+ `${askUpdate} @${assignees[0]}? If you would like to release the bounty back to the DevPool, please comment \`/stop\` \nLast activity time: ${lastActivity}`,
issue.number
);
+ }
}
}
return false;
};
-const lastActivityTime = async (issue: Issue): Promise => {
+const lastActivityTime = async (issue: Issue, comments: Comment[]): Promise => {
const logger = getLogger();
logger.info(`Checking the latest activity for the issue, issue_number: ${issue.number}`);
const assignees = issue.assignees.map((i) => i.login);
const activities: Date[] = [new Date(issue.created_at)];
+ const lastAssignCommentOfHunter = comments
+ .filter((comment) => comment.user.type === UserType.Bot && comment.body.includes(assignees[0]) && comment.body.includes(deadLinePrefix))
+ .sort((a: Comment, b: Comment) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
+ if (lastAssignCommentOfHunter.length > 0) activities.push(new Date(lastAssignCommentOfHunter[0].created_at));
+
// get last comment on the issue
- const lastCommentsOfHunterForIssue = (await getCommentsOfIssue(issue.number))
+ const lastCommentsOfHunterForIssue = comments
.filter((comment) => assignees.includes(comment.user.login))
.sort((a: Comment, b: Comment) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
@@ -100,9 +117,9 @@ const lastActivityTime = async (issue: Issue): Promise => {
const commits = (await getCommitsOnPullRequest(pr.number))
.filter((it) => it.commit.committer?.date)
.sort((a, b) => new Date(b.commit.committer?.date ?? 0).getTime() - new Date(a.commit.committer?.date ?? 0).getTime());
- const prComments = (await getCommentsOfIssue(pr.number))
+ const prComments = (await getAllIssueComments(pr.number))
.filter((comment) => comment.user.login === assignees[0])
- .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
+ .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
if (commits.length > 0) activities.push(new Date(commits[0].commit.committer?.date ?? 0));
if (prComments.length > 0) activities.push(new Date(prComments[0].created_at));
diff --git a/src/handlers/wildcard/weekly.ts b/src/handlers/wildcard/weekly.ts
index 7f05cf077..8d9dbdc70 100644
--- a/src/handlers/wildcard/weekly.ts
+++ b/src/handlers/wildcard/weekly.ts
@@ -2,21 +2,23 @@ import { run } from "./weekly/action";
import { getLastWeeklyTime, updateLastWeeklyTime } from "../../adapters/supabase";
import { getBotConfig, getBotContext } from "../../bindings";
-const SEVEN_DAYS = 604800; // 7 days in seconds
+const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
export const checkWeeklyUpdate = async () => {
const { log } = getBotContext();
const {
- mode: { analyticsMode },
+ mode: { disableAnalytics },
} = getBotConfig();
- if (!analyticsMode) {
- log.info(`Skipping to collect the weekly analytics, reason: mode=${analyticsMode}`);
+ if (disableAnalytics) {
+ log.info(`Skipping to collect the weekly analytics, reason: mode=${disableAnalytics}`);
return;
}
- const curTime = Date.now() / 1000;
+ const curTime = new Date();
const lastTime = await getLastWeeklyTime();
- if (lastTime + SEVEN_DAYS < curTime) {
+ if (lastTime == undefined || new Date(lastTime.getTime() + SEVEN_DAYS) < curTime) {
await run();
await updateLastWeeklyTime(curTime);
+ } else {
+ log.info(`Skipping to collect the weekly analytics because 7 days have not passed`);
}
};
diff --git a/src/handlers/wildcard/weekly/action.ts b/src/handlers/wildcard/weekly/action.ts
index 4a26ca9fe..09e06e878 100644
--- a/src/handlers/wildcard/weekly/action.ts
+++ b/src/handlers/wildcard/weekly/action.ts
@@ -6,7 +6,7 @@ import path from "path";
import axios from "axios";
import Jimp from "jimp";
import nodeHtmlToImage from "node-html-to-image";
-import { getBotContext } from "../../../bindings";
+import { getBotConfig, getBotContext } from "../../../bindings";
import { telegramPhotoNotifier } from "../../../adapters";
import { Context } from "probot";
import { Payload } from "../../../types";
@@ -33,31 +33,41 @@ const fetchEvents = async (context: Context): Promise => {
const perPage = 30;
while (shouldFetch) {
try {
+ let events;
if (payload.organization) {
- const { data: pubOrgEvents, headers } = await context.octokit.activity.listPublicOrgEvents({
+ events = await context.octokit.activity.listPublicOrgEvents({
org: payload.organization.login,
per_page: perPage,
page: currentPage,
});
+ } else {
+ events = await context.octokit.activity.listRepoEvents({
+ owner: payload.repository.owner.login,
+ repo: payload.repository.name,
+ per_page: perPage,
+ page: currentPage,
+ });
+ }
+ const pubEvents = events.data;
+ const headers = events.headers;
- await checkRateLimitGit(headers);
+ await checkRateLimitGit(headers);
- pubOrgEvents.forEach((elem: any) => {
- const elemTimestamp = new Date(elem.created_at as string).getTime();
- if (elemTimestamp <= startTimestamp && elemTimestamp >= endTimestamp) {
- //pass
- elemList.push(elem);
- } else if (elemTimestamp > startTimestamp) {
- //outta range
- //skip
- } else {
- //fail end
- shouldFetch = false;
- }
- });
+ pubEvents.forEach((elem: any) => {
+ const elemTimestamp = new Date(elem.created_at as string).getTime();
+ if (elemTimestamp <= startTimestamp && elemTimestamp >= endTimestamp) {
+ //pass
+ elemList.push(elem);
+ } else if (elemTimestamp > startTimestamp) {
+ //outta range
+ //skip
+ } else {
+ //fail end
+ shouldFetch = false;
+ }
+ });
- currentPage++;
- }
+ currentPage++;
} catch (error) {
shouldFetch = false;
}
@@ -339,5 +349,12 @@ export const run = async () => {
const dataPadded = await fetchSummary(repository);
await htmlImage(summaryInfo);
await compositeImage();
- await processTelegram(dataPadded);
+
+ const { telegram } = getBotConfig();
+ if (telegram.token) {
+ await processTelegram(dataPadded);
+ } else {
+ const log = context.log;
+ log.info("Skipping processTelegram because no token was set.");
+ }
};
diff --git a/src/helpers/comment.ts b/src/helpers/comment.ts
index 825958cc9..7be39376c 100644
--- a/src/helpers/comment.ts
+++ b/src/helpers/comment.ts
@@ -1,45 +1,140 @@
-type MdastNode = {
- type: string;
- value: string;
- children: MdastNode[];
+import Decimal from "decimal.js";
+import { isEmpty } from "lodash";
+import * as parse5 from "parse5";
+
+type Node = {
+ nodeName: string;
+ tagName?: string;
+ value?: string;
+ childNodes?: Node[];
};
-const cachedResult: Record = {};
-const traverse = (node: MdastNode, itemsToExclude: string[]): Record => {
- if (!cachedResult[node.type]) {
- cachedResult[node.type] = [];
+
+const traverse = (result: Record, node: Node, itemsToExclude: string[]): Record => {
+ if (itemsToExclude.includes(node.nodeName)) {
+ return result;
}
- if (!itemsToExclude.includes(node.type)) {
- // skip pushing if the node type has been excluded
- cachedResult[node.type].push(node.value);
- } else if (node.children.length > 0) {
- node.children.forEach((child) => traverse(child, itemsToExclude));
+ if (!result[node.nodeName]) {
+ result[node.nodeName] = [];
}
- return cachedResult;
-};
+ result[node.nodeName].push(node.value?.trim() ?? "");
+
+ if (node.childNodes && node.childNodes.length > 0) {
+ node.childNodes.forEach((child) => traverse(result, child, itemsToExclude));
+ }
-export const parseComments = async (comments: string[], itemsToExclude: string[]): Promise> => {
- const { fromMarkdown } = await import("mdast-util-from-markdown");
- const { gfmFromMarkdown } = await import("mdast-util-gfm");
- const { gfm } = await import("micromark-extension-gfm");
+ return result;
+};
+export const parseComments = (comments: string[], itemsToExclude: string[]): Record => {
const result: Record = {};
+
for (const comment of comments) {
- const tree = fromMarkdown(comment, {
- extensions: [gfm()],
- mdastExtensions: [gfmFromMarkdown()],
- });
-
- const parsedContent = traverse(tree as MdastNode, itemsToExclude);
- for (const key of Object.keys(parsedContent)) {
- if (Object.keys(result).includes(key)) {
- result[key].push(...parsedContent[key]);
- } else {
- result[key] = parsedContent[key];
- }
- }
+ const fragment = parse5.parseFragment(comment);
+ traverse(result, fragment as Node, itemsToExclude);
+ }
+
+ // remove empty values
+ if (result["#text"]) {
+ result["#text"] = result["#text"].filter((str) => str.length > 0);
}
return result;
};
+
+export const generateCollapsibleTable = (data: { element: string; units: number; reward: Decimal }[]) => {
+ // Check if the data array is empty
+ if (data.length === 0) {
+ return "No data to display.";
+ }
+
+ // Create the table header row
+ const headerRow = "| element | units | reward |\n| --- | --- | --- |";
+
+ // Create the table rows from the data array
+ const tableRows = data.map((item) => `| ${item.element} | ${item.units} | ${item.reward} |`).join("\n");
+
+ // Create the complete Markdown table
+ const tableMarkdown = `
+
+ Details
+
+${headerRow}
+${tableRows}
+
+
+ `;
+
+ return tableMarkdown;
+};
+
+export const createDetailsTable = (
+ amount: string,
+ paymentURL: string,
+ username: string,
+ values: { title: string; subtitle: string; value: string }[],
+ debug: Record<
+ string,
+ {
+ count: number;
+ reward: Decimal;
+ }
+ >
+): string => {
+ let collapsibleTable = null;
+ // Generate the table rows based on the values array
+ const tableRows = values
+ .map(({ title, value, subtitle }) => {
+ if (!subtitle || !value) {
+ return "";
+ }
+ return `