diff --git a/.github/ISSUE_TEMPLATE/reward-task.yml b/.github/ISSUE_TEMPLATE/reward-task.yml new file mode 100644 index 0000000..fbf1b93 --- /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 0000000..6060e6f --- /dev/null +++ b/.github/scripts/count-reward.ts @@ -0,0 +1,57 @@ +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 --global user.name "github-actions[bot]"`; +await $`git config --global user.email "github-actions[bot]@users.noreply.github.com"`; + +await $`git tag -a ${tagName} $(git rev-parse HEAD) -m ${summaryText}`; +await $`git push origin --tags`; + +await $`gh release create ${tagName} --notes ${summaryText}`; diff --git a/.github/scripts/share-reward.ts b/.github/scripts/share-reward.ts new file mode 100644 index 0000000..953ef1a --- /dev/null +++ b/.github/scripts/share-reward.ts @@ -0,0 +1,98 @@ +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 PR_URL = await $`gh api graphql -f query='{ + repository(owner: "${repositoryOwner}", name: "${repositoryName}") { + issue(number: ${issueNumber}) { + closedByPullRequestsReferences(first: 10) { + nodes { + url + merged + } + } + } + } +}' --jq '.data.repository.issue.closedByPullRequestsReferences.nodes[] | select(.merged == true) | .url' | head -n 1`; + +if (!PR_URL.text().trim()) + throw new ReferenceError('No merged PR is found for the given issue number.'); + +const { author, assignees }: PRMeta = await ( + await $`gh pr view ${PR_URL} --json author,assignees` +).json(); + +// Function to check if a user is a Copilot/bot user +function isCopilotUser(login: string): boolean { + const lowerLogin = login.toLowerCase(); + return ( + lowerLogin.includes('copilot') || + lowerLogin.includes('[bot]') || + lowerLogin === 'github-actions[bot]' || + lowerLogin.endsWith('[bot]') + ); +} + +// Filter out Copilot and bot users from the list +const allUsers = [author.login, ...assignees.map(({ login }) => login)]; +const users = allUsers.filter(login => !isCopilotUser(login)); + +console.log(`All users: ${allUsers.join(', ')}`); +console.log(`Filtered users (excluding bots/copilot): ${users.join(', ')}`); + +// Handle case where all users are bots/copilot +if (users.length === 0) { + console.log('No real users found (all users are bots/copilot). Skipping reward distribution.'); + console.log(`Filtered users: ${allUsers.join(', ')}`); + process.exit(0); +} + +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 --global user.name "github-actions[bot]"`; +await $`git config --global user.email "github-actions[bot]@users.noreply.github.com"`; +await $`git tag -a "reward-${issueNumber}" -m ${listText}`; +await $`git push origin --tags`; + +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 0000000..e61d2f0 --- /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 0000000..e9c70a5 --- /dev/null +++ b/.github/workflows/claim-issue-reward.yml @@ -0,0 +1,40 @@ +name: Claim Issue Reward +on: + issues: + types: + - closed +env: + GH_TOKEN: ${{ github.token }} + +jobs: + claim-issue-reward: + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: read + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Get Issue details + id: parse_issue + uses: stefanbuck/github-issue-parser@v3 + with: + template-path: '.github/ISSUE_TEMPLATE/reward-task.yml' + + - name: Calculate & Save Reward + run: | + deno --allow-all .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 0000000..4c6fb13 --- /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 +env: + GH_TOKEN: ${{ github.token }} + +jobs: + statistic-member-reward: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + 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@v2 + if: env.NEW_COMMITS == 'true' + with: + deno-version: v2.x + + - name: Statistic rewards + if: env.NEW_COMMITS == 'true' + run: deno --allow-all .github/scripts/count-reward.ts