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: