diff --git a/.github/ISSUE_TEMPLATE/reward-task.yml b/.github/ISSUE_TEMPLATE/reward-task.yml new file mode 100644 index 00000000..b9bc7c21 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/reward-task.yml @@ -0,0 +1,48 @@ +name: 💰 Reward Task +description: Task issue with Reward +title: '[Reward] ' +labels: + - reward +body: + - type: textarea + id: description + attributes: + label: Task description + validations: + required: true + + - type: dropdown + id: currency + attributes: + label: Reward currency + options: + - 'USD $' + - 'CAD C$' + - 'AUD A$' + - 'GBP £' + - 'EUR €' + - 'CNY ¥' + - 'HKD HK$' + - 'TWD NT$' + - 'SGD S$' + - 'KRW ₩' + - 'JPY ¥' + - 'INR ₹' + - 'UAH ₴' + validations: + required: true + + - type: input + id: amount + attributes: + label: Reward amount + validations: + required: true + + - type: input + id: payer + attributes: + label: Reward payer + description: GitHub username of the payer (optional, defaults to issue creator) + validations: + required: false diff --git a/.github/scripts/count-reward.ts b/.github/scripts/count-reward.ts new file mode 100644 index 00000000..99d5921c --- /dev/null +++ b/.github/scripts/count-reward.ts @@ -0,0 +1,59 @@ +import { $, YAML } from "npm:zx"; + +import { Reward } from "./type.ts"; + +$.verbose = true; + +const rawTags = + await $`git tag --list "reward-*" --format="%(refname:short) %(creatordate:short)"`; + +const lastMonth = new Date(); +lastMonth.setMonth(lastMonth.getMonth() - 1); +const lastMonthStr = lastMonth.toJSON().slice(0, 7); + +const rewardTags = rawTags.stdout + .split("\n") + .filter((line) => line.split(/\s+/)[1] >= lastMonthStr) + .map((line) => line.split(/\s+/)[0]); + +let rawYAML = ""; + +for (const tag of rewardTags) + rawYAML += (await $`git tag -l --format="%(contents)" ${tag}`) + "\n"; + +if (!rawYAML.trim()) + throw new ReferenceError("No reward data is found for the last month."); + +const rewards = YAML.parse(rawYAML) as Reward[]; + +const groupedRewards = Object.groupBy(rewards, ({ payee }) => payee); + +const summaryList = Object.entries(groupedRewards).map(([payee, rewards]) => { + const reward = rewards!.reduce((acc, { currency, reward }) => { + acc[currency] ??= 0; + acc[currency] += reward; + return acc; + }, {} as Record); + + return { + payee, + reward, + accounts: rewards!.map(({ payee: _, ...account }) => account), + }; +}); + +const summaryText = YAML.stringify(summaryList); + +console.log(summaryText); + +const tagName = `statistic-${new Date().toJSON().slice(0, 7)}`; + +await $`git config user.name "github-actions[bot]"`; +await $`git config user.email "github-actions[bot]@users.noreply.github.com"`; + +const headCommit = (await $`git rev-parse HEAD`).stdout.trim(); + +await $`git tag -a ${tagName} ${headCommit} -m ${summaryText}`; +await $`git push origin --tags --no-verify`; + +await $`gh release create ${tagName} --notes ${summaryText}`; diff --git a/.github/scripts/deno.json b/.github/scripts/deno.json new file mode 100644 index 00000000..c406264d --- /dev/null +++ b/.github/scripts/deno.json @@ -0,0 +1,3 @@ +{ + "nodeModulesDir": "none" +} diff --git a/.github/scripts/share-reward.ts b/.github/scripts/share-reward.ts new file mode 100644 index 00000000..100116bf --- /dev/null +++ b/.github/scripts/share-reward.ts @@ -0,0 +1,116 @@ +import { components } from "npm:@octokit/openapi-types"; +import { $, argv, YAML } from "npm:zx"; + +import { Reward } from "./type.ts"; + +$.verbose = true; + +const [ + repositoryOwner, + repositoryName, + issueNumber, + payer, // GitHub username of the payer (provided by workflow, defaults to issue creator) + currency, + reward, +] = argv._; + +interface PRMeta { + author: components["schemas"]["simple-user"]; + assignees: components["schemas"]["simple-user"][]; +} + +const graphqlQuery = ` + query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + issue(number: $number) { + closedByPullRequestsReferences(first: 10) { + nodes { + url + merged + mergeCommit { + oid + } + } + } + } + } + } +`; + +const PR_DATA = await $`gh api graphql \ + -f query=${graphqlQuery} \ + -f owner=${repositoryOwner} \ + -f name=${repositoryName} \ + -F number=${issueNumber} \ + --jq '.data.repository.issue.closedByPullRequestsReferences.nodes[] | select(.merged == true) | {url: .url, mergeCommitSha: .mergeCommit.oid}' | head -n 1`; + +const prData = PR_DATA.text().trim(); + +if (!prData) + throw new ReferenceError("No merged PR is found for the given issue number."); + +const { url: PR_URL, mergeCommitSha } = JSON.parse(prData); + +if (!PR_URL || !mergeCommitSha) + throw new Error("Missing required fields in PR data"); + +console.table({ PR_URL, mergeCommitSha }); + +const { author, assignees }: PRMeta = await ( + await $`gh pr view ${PR_URL} --json author,assignees` +).json(); + +function isBotUser(login: string) { + const lowerLogin = login.toLowerCase(); + return ( + lowerLogin.includes("copilot") || + lowerLogin.includes("[bot]") || + lowerLogin === "github-actions[bot]" || + lowerLogin.endsWith("[bot]") + ); +} + +// Filter out Bot users from the list +const allUsers = [author.login, ...assignees.map(({ login }) => login)]; +const users = allUsers.filter((login) => !isBotUser(login)); + +console.log(`All users: ${allUsers.join(", ")}`); +console.log(`Filtered users (excluding bots): ${users.join(", ")}`); + +if (!users[0]) + throw new ReferenceError( + "No real users found (all users are bots). Skipping reward distribution." + ); + +const rewardNumber = parseFloat(reward); + +if (isNaN(rewardNumber) || rewardNumber <= 0) + throw new RangeError( + `Reward amount is not a valid number, can not proceed with reward distribution. Received reward value: ${reward}` + ); + +const averageReward = (rewardNumber / users.length).toFixed(2); + +const list: Reward[] = users.map((login) => ({ + issue: `#${issueNumber}`, + payer: `@${payer}`, + payee: `@${login}`, + currency, + reward: parseFloat(averageReward), +})); +const listText = YAML.stringify(list); + +console.log(listText); + +await $`git config user.name "github-actions[bot]"`; +await $`git config user.email "github-actions[bot]@users.noreply.github.com"`; +await $`git tag -a "reward-${issueNumber}" ${mergeCommitSha} -m ${listText}`; +await $`git push origin --tags --no-verify`; + +const commentBody = `## Reward data + +\`\`\`yml +${listText} +\`\`\` +`; +await $`gh issue comment ${issueNumber} --body ${commentBody}`; diff --git a/.github/scripts/type.ts b/.github/scripts/type.ts new file mode 100644 index 00000000..e61d2f03 --- /dev/null +++ b/.github/scripts/type.ts @@ -0,0 +1,7 @@ +export interface Reward { + issue: string; + payer: string; + payee: string; + currency: string; + reward: number; +} diff --git a/.github/workflows/claim-issue-reward.yml b/.github/workflows/claim-issue-reward.yml new file mode 100644 index 00000000..0dd23bbf --- /dev/null +++ b/.github/workflows/claim-issue-reward.yml @@ -0,0 +1,55 @@ +name: Claim Issue Reward +on: + issues: + types: + - closed + +concurrency: + group: claim-issue-reward-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + claim-issue-reward: + runs-on: ubuntu-latest + if: contains(github.event.issue.labels.*.name, 'reward') + permissions: + contents: write + issues: write + pull-requests: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 + with: + deno-version: v2.x + + - name: Get Issue details + id: parse_issue + uses: stefanbuck/github-issue-parser@10dcc54158ba4c137713d9d69d70a2da63b6bda3 # v3.2.3 + with: + template-path: ".github/ISSUE_TEMPLATE/reward-task.yml" + + - name: Validate amount + env: + AMOUNT: ${{ steps.parse_issue.outputs.issueparser_amount }} + run: | + if ! [[ "$AMOUNT" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then + echo "Invalid amount: $AMOUNT" + exit 1 + fi + + - name: Calculate & Save Reward + env: + GH_TOKEN: ${{ github.token }} + run: | + deno --allow-run --allow-env --allow-read --allow-net=api.github.com \ + .github/scripts/share-reward.ts \ + "${{ github.repository_owner }}" \ + "${{ github.event.repository.name }}" \ + "${{ github.event.issue.number }}" \ + "${{ steps.parse_issue.outputs.issueparser_payer || github.event.issue.user.login }}" \ + "${{ steps.parse_issue.outputs.issueparser_currency }}" \ + "${{ steps.parse_issue.outputs.issueparser_amount }}" diff --git a/.github/workflows/statistic-member-reward.yml b/.github/workflows/statistic-member-reward.yml new file mode 100644 index 00000000..c6000053 --- /dev/null +++ b/.github/workflows/statistic-member-reward.yml @@ -0,0 +1,43 @@ +name: Statistic Member Reward +on: + schedule: + - cron: "0 0 1 * *" # Run at 00:00 on the first day of every month + +jobs: + statistic-member-reward: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Check for new commits since last statistic + run: | + last_tag=$(git describe --tags --abbrev=0 --match "statistic-*" || echo "") + + if [ -z "$last_tag" ]; then + echo "No previous statistic tags found." + echo "NEW_COMMITS=true" >> $GITHUB_ENV + else + new_commits=$(git log $last_tag..HEAD --oneline) + if [ -z "$new_commits" ]; then + echo "No new commits since last statistic tag." + echo "NEW_COMMITS=false" >> $GITHUB_ENV + else + echo "New commits found." + echo "NEW_COMMITS=true" >> $GITHUB_ENV + fi + fi + - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 + if: env.NEW_COMMITS == 'true' + with: + deno-version: v2.x + + - name: Statistic rewards + if: env.NEW_COMMITS == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: deno --allow-run --allow-env --allow-read --allow-net=api.github.com .github/scripts/count-reward.ts