diff --git a/.github/ISSUE_TEMPLATE/reward-task.yml b/.github/ISSUE_TEMPLATE/reward-task.yml new file mode 100644 index 0000000..b9bc7c2 --- /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..154d3f3 --- /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 user.name "github-actions[bot]"`; +await $`git config 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/deno.json b/.github/scripts/deno.json new file mode 100644 index 0000000..c406264 --- /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 0000000..2e6b7d1 --- /dev/null +++ b/.github/scripts/share-reward.ts @@ -0,0 +1,113 @@ +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/transform-message.ts b/.github/scripts/transform-message.ts new file mode 100644 index 0000000..cb2854d --- /dev/null +++ b/.github/scripts/transform-message.ts @@ -0,0 +1,270 @@ +import { components } from 'npm:@octokit/openapi-types'; +import { stdin } from 'npm:zx'; + +type GitHubSchema = components['schemas']; + +type GitHubUser = GitHubSchema['simple-user']; + +interface GitHubAction extends Record< + 'event_name' | 'actor' | 'server_url' | 'repository', + string +> { + action?: string; + ref?: string; + ref_name?: string; + event: { + head_commit?: GitHubSchema['git-commit']; + issue?: GitHubSchema['webhook-issues-opened']['issue']; + pull_request?: GitHubSchema['pull-request']; + discussion?: GitHubSchema['discussion']; + comment?: GitHubSchema['issue-comment']; + release?: GitHubSchema['release']; + }; +} + +// Helper functions +const ACTION_TEXT_MAP: Record = { + created: '创建', + opened: '创建', + submitted: '创建', + closed: '关闭', + reopened: '重新打开', + labeled: '添加标签', + unlabeled: '移除标签', + assigned: '指派', + unassigned: '取消指派', + edited: '编辑', + deleted: '删除', + synchronize: '更新', + review_requested: '请求审核' +}; + +const getActionText = (action?: string) => (action ? ACTION_TEXT_MAP[action] || action : '编辑'); + +const createLink = (href: string, text = href) => `[${text}](${href})`; + +const createUserLink = (user: any) => (user ? createLink(user.html_url, user.login) : '无'); + +// Convert GitHub markdown to Lark card supported format +const sanitizeMarkdown = (text: string): string => + text + // Remove code blocks + .replace(/```[\s\S]*?```/g, '[代码块]') + // Remove inline code + .replace(/`[^`]+`/g, match => match.slice(1, -1)) + // Convert images to link text + .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '🖼️ [$1]($2)') + // Convert ### headers to bold + .replace(/^###\s+(.+)$/gm, '**$1**') + // Convert ## headers to bold + .replace(/^##\s+(.+)$/gm, '**$1**') + // Convert # headers to bold + .replace(/^#\s+(.+)$/gm, '**$1**') + // Remove HTML comments + .replace(//g, '') + // Remove HTML tags (keep content) + .replace(/<[^>]+>/g, '') + // Remove excess empty lines + .replace(/\n{3,}/g, '\n\n') + // Truncate long content + .slice(0, 800) + (text.length > 800 ? '\n...' : ''); + +const createContentItem = (label: string, value?: string) => + `**${label}** ${value ? sanitizeMarkdown(value) : '无'}`; + +interface LarkMessageElement { + tag: string; + content: string | [object, object][]; +} + +type EventHandler = ( + event: GitHubAction, + actionText: string +) => { + title: string; + elements: LarkMessageElement[]; +}; + +// Event handlers +const eventHandlers: Record = { + push: ({ event: { head_commit }, ref, ref_name, server_url, repository, actor }) => { + const commitUrl = head_commit?.url || `${server_url}/${repository}/tree/${ref_name}`; + const commitMessage = + head_commit?.message || 'Create/Delete/Update Branch (No head commit)'; + + return { + title: 'GitHub 代码提交', + elements: [ + { + tag: 'markdown', + content: [ + createContentItem('提交链接:', createLink(commitUrl)), + createContentItem( + '代码分支:', + createLink(`${server_url}/${repository}/tree/${ref_name}`, ref_name) + ), + createContentItem( + '提交作者:', + createLink(`${server_url}/${actor}`, actor) + ), + createContentItem('提交信息:', commitMessage) + ].join('\n') + } + ] + }; + }, + + issues: ({ event: { issue } }, actionText) => ({ + title: `GitHub issue ${actionText}:${issue?.title}`, + elements: [ + { + tag: 'markdown', + content: [ + createContentItem('链接:', createLink(issue!.html_url)), + createContentItem('作者:', createUserLink(issue!.user!)), + createContentItem( + '指派:', + issue?.assignee ? createUserLink(issue.assignee) : '无' + ), + createContentItem( + '标签:', + issue?.labels?.map(({ name }) => name).join(', ') || '无' + ), + createContentItem('里程碑:', issue?.milestone?.title || '无'), + createContentItem('描述:', issue?.body || '无') + ].join('\n') + } + ] + }), + + pull_request: ({ event: { pull_request } }, actionText) => ({ + title: `GitHub PR ${actionText}:${pull_request?.title}`, + elements: [ + { + tag: 'markdown', + content: [ + createContentItem('链接:', createLink(pull_request!.html_url)), + createContentItem('作者:', createUserLink(pull_request!.user)), + createContentItem( + '指派:', + pull_request?.assignee ? createUserLink(pull_request.assignee) : '无' + ), + createContentItem( + '标签:', + pull_request?.labels?.map(({ name }) => name).join(', ') || '无' + ), + createContentItem('里程碑:', pull_request?.milestone?.title || '无'), + createContentItem('描述:', pull_request?.body || '无') + ].join('\n') + } + ] + }), + + discussion: ({ event: { discussion } }, actionText) => ({ + title: `GitHub 讨论 ${actionText}:${discussion?.title || '无'}`, + elements: [ + { + tag: 'markdown', + content: [ + createContentItem('链接:', createLink(discussion!.html_url)), + createContentItem('作者:', createUserLink(discussion!.user as GitHubUser)), + createContentItem('描述:', discussion?.body || '无') + ].join('\n') + } + ] + }), + + issue_comment: ({ event: { comment, issue } }) => ({ + title: `GitHub issue 评论:${issue?.title || '未知 issue'}`, + elements: [ + { + tag: 'markdown', + content: [ + createContentItem('链接:', createLink(comment!.html_url)), + createContentItem('作者:', createUserLink(comment!.user!)), + createContentItem('描述:', comment?.body || '无') + ].join('\n') + } + ] + }), + + discussion_comment: ({ event: { comment, discussion } }) => ({ + title: `GitHub 讨论评论:${discussion?.title || '无'}`, + elements: [ + { + tag: 'markdown', + content: [ + createContentItem('链接:', createLink(comment!.html_url)), + createContentItem('作者:', createUserLink(comment!.user!)), + createContentItem('描述:', comment?.body || '无') + ].join('\n') + } + ] + }), + + release: ({ event: { release } }) => ({ + title: `GitHub Release 发布:${release!.name || release!.tag_name}`, + elements: [ + { + tag: 'markdown', + content: [ + createContentItem('链接:', createLink(release!.html_url)), + createContentItem('作者:', createUserLink(release!.author)), + createContentItem('描述:', release?.body || '无') + ].join('\n') + } + ] + }), + + pull_request_review_comment: ({ event: { comment, pull_request } }) => ({ + title: `GitHub PR 代码评论:${pull_request?.title || '未知 PR'}`, + elements: [ + { + tag: 'markdown', + content: [ + createContentItem('链接:', createLink(comment!.html_url)), + createContentItem('作者:', createUserLink(comment!.user!)), + createContentItem( + 'PR:', + createLink(pull_request!.html_url, `#${pull_request!.number}`) + ), + createContentItem('评论:', comment?.body || '无') + ].join('\n') + } + ] + }) +}; + +// Main processor +const processEvent = (event: GitHubAction) => { + const { event_name, action } = event; + const actionText = getActionText(action); + const handler = eventHandlers[event_name]; + + if (!handler) throw new Error(`No handler found for event: ${event_name}`); + + try { + return handler(event, actionText); + } catch (cause) { + throw new Error(`Error processing ${event_name} event: ${(cause as Error).message}`, { + cause + }); + } +}; + +// Main execution +const event = JSON.parse((await stdin()) || '{}') as GitHubAction; +const result = processEvent(event); + +if (!result) throw new Error(`Unsupported ${event.event_name} event & ${event.action} action`); + +const card = { + schema: '2.0', + config: { wide_screen_mode: true }, + header: { + title: { tag: 'plain_text', content: result.title }, + template: 'blue' + }, + body: { elements: result.elements } +}; +console.log(JSON.stringify(card)); diff --git a/.github/scripts/type.ts b/.github/scripts/type.ts new file mode 100644 index 0000000..6f4e2e7 --- /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/Lark-notification.yml b/.github/workflows/Lark-notification.yml new file mode 100644 index 0000000..3783f45 --- /dev/null +++ b/.github/workflows/Lark-notification.yml @@ -0,0 +1,47 @@ +name: Lark notification + +# https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows +on: + push: + issues: + pull_request: + discussion: + issue_comment: + discussion_comment: + pull_request_review_comment: + release: + types: + - published + +jobs: + send-Lark-message: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Event Message serialization + id: message + run: | + YAML=$( + cat <> "$GITHUB_OUTPUT" + + - name: Send message to Lark + if: ${{ contains(steps.message.outputs.content, ':') }} + uses: Open-Source-Bazaar/feishu-action@v3 + with: + url: ${{ secrets.LARK_CHATBOT_HOOK_URL }} + msg_type: interactive + content: | + ${{ steps.message.outputs.content }} diff --git a/.github/workflows/claim-issue-reward.yml b/.github/workflows/claim-issue-reward.yml new file mode 100644 index 0000000..880d7fb --- /dev/null +++ b/.github/workflows/claim-issue-reward.yml @@ -0,0 +1,46 @@ +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: Calculate & Save Reward + env: + GH_TOKEN: ${{ github.token }} + run: | + deno --allow-run --allow-sys --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 0000000..0105a4c --- /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-sys --allow-env --allow-read --allow-net=api.github.com .github/scripts/count-reward.ts diff --git a/.vscode/extensions.json b/.vscode/extensions.json index affda41..cb81053 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -3,13 +3,13 @@ "yzhang.markdown-all-in-one", "redhat.vscode-yaml", "akamud.vscode-caniuse", - "visualstudioexptteam.intellicode-api-usage-examples", "pflannery.vscode-versionlens", "christian-kohler.npm-intellisense", "esbenp.prettier-vscode", "rangav.vscode-thunder-client", "eamodio.gitlens", "github.vscode-pull-request-github", - "github.vscode-github-actions" + "github.vscode-github-actions", + "github.copilot-chat" ] }