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';

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the loop building rawYAML, you’re concatenating the $ command result object directly into a string. In zx this will not reliably produce the command stdout (often becomes "[object Object]"), which will break YAML.parse. Use the command output explicitly (e.g., .stdout or .text()) when appending tag contents.

Suggested change
for (const tag of rewardTags) rawYAML += (await $`git tag -l --format="%(contents)" ${tag}`) + '\n';
for (const tag of rewardTags) {
const tagContents = await $`git tag -l --format="%(contents)" ${tag}`;
rawYAML += tagContents.stdout + '\n';
}

Copilot uses AI. Check for mistakes.

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 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}`;
3 changes: 3 additions & 0 deletions .github/scripts/deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"nodeModulesDir": "none"
}
Comment on lines +1 to +3

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This deno.json lives under .github/scripts/, but the workflows run deno ... .github/scripts/*.ts from the repo root. Deno only auto-loads config from the current directory (and ancestors), so this config likely won’t be applied unless you pass --config .github/scripts/deno.json or move the config to the repository root.

Copilot uses AI. Check for mistakes.
113 changes: 113 additions & 0 deletions .github/scripts/share-reward.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment on lines +39 to +50

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gh api ... --jq '...{...}' | head -n 1 is brittle because --jq output for objects is typically multi-line JSON; head -n 1 can truncate it (e.g., just "{") and make JSON.parse(prData) fail. Prefer making the jq output single-line (compact) or extracting a single JSON-encoded line before piping to head, so the parsed string is always valid JSON.

Copilot uses AI. Check for mistakes.

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));
Comment on lines +70 to +72

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

allUsers can contain duplicates (e.g., author is also an assignee), and the current filter doesn’t dedupe. That will incorrectly increase users.length, reduce per-person reward, and may create multiple entries for the same payee. Consider normalizing to unique logins (e.g., via a Set) before calculating the split.

Suggested change
// Filter out Bot users from the list
const allUsers = [author.login, ...assignees.map(({ login }) => login)];
const users = allUsers.filter(login => !isBotUser(login));
// Filter out Bot users from the list and deduplicate logins
const allUsers = [author.login, ...assignees.map(({ login }) => login)];
const users = [...new Set(allUsers.filter(login => !isBotUser(login)))];

Copilot uses AI. Check for mistakes.

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}`;
Loading
Loading