chore: add GitHub-reward form, scripts & actions (#221)

* chore: add GitHub-reward form, scripts & actions

* fix: Deno system permissions in GitHub actions

* fix: 3 detail bugs
This commit is contained in:
South Drifter 2026-04-07 19:02:59 +08:00 committed by GitHub
parent c2981836bb
commit 66acc6e57b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 338 additions and 0 deletions

48
.github/ISSUE_TEMPLATE/reward-task.yml vendored Normal file
View file

@ -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

66
.github/scripts/count-reward.ts vendored Normal file
View file

@ -0,0 +1,66 @@
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()) {
console.warn("No reward data is found for the last month.");
process.exit(0);
}
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 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 $`git config unset user.name`;
await $`git config unset user.email`;
await $`gh release create ${tagName} --notes ${summaryText}`;

3
.github/scripts/deno.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"nodeModulesDir": "none"
}

125
.github/scripts/share-reward.ts vendored Normal file
View file

@ -0,0 +1,125 @@
import "npm:array-unique-proposal";
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),
].uniqueBy();
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`;
await $`git config unset user.name`;
await $`git config unset user.email`;
const commentBody = `## Reward data
\`\`\`yml
${listText}
\`\`\`
`;
await $`gh issue comment ${issueNumber} --body ${commentBody}`;

7
.github/scripts/type.ts vendored Normal file
View file

@ -0,0 +1,7 @@
export interface Reward {
issue: string;
payer: string;
payee: string;
currency: string;
reward: number;
}

View file

@ -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 }}"

View file

@ -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