Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/ISSUE_TEMPLATE/reward-task.yml
Original file line number Diff line number Diff line change
@@ -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
57 changes: 57 additions & 0 deletions .github/scripts/count-reward.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>,
);

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}`;
98 changes: 98 additions & 0 deletions .github/scripts/share-reward.ts
Original file line number Diff line number Diff line change
@@ -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}`;
7 changes: 7 additions & 0 deletions .github/scripts/type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface Reward {
issue: string;
payer: string;
payee: string;
currency: string;
reward: number;
}
40 changes: 40 additions & 0 deletions .github/workflows/claim-issue-reward.yml
Original file line number Diff line number Diff line change
@@ -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 }}
43 changes: 43 additions & 0 deletions .github/workflows/statistic-member-reward.yml
Original file line number Diff line number Diff line change
@@ -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