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..de70800 --- /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 --no-verify`; + +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..ea3480e --- /dev/null +++ b/.github/scripts/share-reward.ts @@ -0,0 +1,105 @@ +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_DATA = await $`gh api graphql -f query='{ + repository(owner: "${repositoryOwner}", name: "${repositoryName}") { + issue(number: ${issueNumber}) { + closedByPullRequestsReferences(first: 10) { + nodes { + url + merged + mergeCommit { + oid + } + } + } + } + } +}' --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 --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}" ${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 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/main.yml b/.github/workflows/main.yml index e448cd7..9e3ce67 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,53 +10,71 @@ jobs: VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@v6 if: ${{ env.VERCEL_TOKEN && env.VERCEL_ORG_ID && env.VERCEL_PROJECT_ID }} + + - uses: actions/setup-node@v6 + if: ${{ env.VERCEL_TOKEN && env.VERCEL_ORG_ID && env.VERCEL_PROJECT_ID }} with: - submodules: recursive - lfs: true - - run: git submodule update --remote + node-version: 24 - name: Deploy to Vercel id: vercel-deployment - uses: amondnet/vercel-action@v25 if: ${{ env.VERCEL_TOKEN && env.VERCEL_ORG_ID && env.VERCEL_PROJECT_ID }} - with: - vercel-token: ${{ secrets.VERCEL_TOKEN }} - github-token: ${{ secrets.GITHUB_TOKEN }} - vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} - vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} - working-directory: ./ - vercel-args: ${{ github.ref == 'refs/heads/main' && '--prod' || '' }} + shell: bash + run: | + set -euo pipefail + + npm install vercel -g + + if [[ "$GITHUB_REF" == 'refs/heads/main' ]]; then + DeployOutput=$(vercel -t "$VERCEL_TOKEN" --prod) + else + DeployOutput=$(vercel -t "$VERCEL_TOKEN") + fi + echo "$DeployOutput" + + ParsedURL=$(echo "$DeployOutput" | grep -Eo 'https://[^[:space:]]*\.vercel\.app' | tail -n 1) + + if [[ -z "$ParsedURL" ]]; then + echo "Failed to parse Vercel URL from deploy output" + exit 1 + fi + vercel inspect "$ParsedURL" -t "$VERCEL_TOKEN" -F json > vercel-inspect.json + + InspectURL=$(jq -r '.url // empty' vercel-inspect.json) + + if [[ -z "$InspectURL" ]]; then + echo "Failed to parse inspect url from vercel-inspect.json" + exit 1 + fi + if [[ "$InspectURL" != http* ]]; then + InspectURL="https://$InspectURL" + fi + echo "preview-url=$InspectURL" >> "$GITHUB_OUTPUT" - name: Lark notification uses: Open-Source-Bazaar/feishu-action@v3 with: url: ${{ secrets.LARK_CHATBOT_HOOK_URL }} - msg_type: post + msg_type: interactive content: | - post: - zh_cn: - title: Vercel 预览环境 - content: - - - tag: text - text: Git 仓库: - - tag: a - text: ${{ github.server_url }}/${{ github.repository }} - href: ${{ github.server_url }}/${{ github.repository }} - - - tag: text - text: 代码分支: - - tag: a - text: ${{ github.ref }} - href: ${{ github.server_url }}/${{ github.repository }}/tree/${{ github.ref_name }} - - - tag: text - text: 提交作者: - - tag: a - text: ${{ github.actor }} - href: ${{ github.server_url }}/${{ github.actor }} - - - tag: text - text: 预览链接: - - tag: a - text: ${{ steps.vercel-deployment.outputs.preview-url }} - href: ${{ steps.vercel-deployment.outputs.preview-url }} + schema: "2.0" + config: + wide_screen_mode: true + header: + title: + tag: plain_text + content: Vercel 部署通知 + template: blue + body: + elements: + - tag: markdown + content: | + **Git 仓库:** [${{ github.server_url }}/${{ github.repository }}](${{ github.server_url }}/${{ github.repository }}) + **代码分支:** [${{ github.ref }}](${{ github.server_url }}/${{ github.repository }}/tree/${{ github.ref_name }}) + **提交作者:** [${{ github.actor }}](${{ github.server_url }}/${{ github.actor }}) + **预览链接:** [${{ steps.vercel-deployment.outputs.preview-url }}](${{ steps.vercel-deployment.outputs.preview-url }}) 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