mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 20:44:32 -05:00
Add GitHub workflow and script for counting monthly contributor points (#5147)
This commit is contained in:
committed by
GitHub
parent
180431f9ed
commit
fbd6989a18
28
.github/actions/get-next-package-version.js
vendored
28
.github/actions/get-next-package-version.js
vendored
@@ -13,7 +13,7 @@ const options = {
|
||||
type: 'string',
|
||||
short: 'p',
|
||||
},
|
||||
'type': {
|
||||
type: {
|
||||
type: 'string', // nightly, hotfix, monthly
|
||||
short: 't',
|
||||
},
|
||||
@@ -25,8 +25,10 @@ const { values } = parseArgs({
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
if (!values["package-json"]) {
|
||||
console.error('Please specify the path to package.json using --package-json or -p option.');
|
||||
if (!values['package-json']) {
|
||||
console.error(
|
||||
'Please specify the path to package.json using --package-json or -p option.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -45,14 +47,24 @@ try {
|
||||
|
||||
// Create date and add 1 month
|
||||
const versionDate = new Date(2000 + versionYear, versionMonth - 1, 1); // month is 0-indexed
|
||||
const nextVersionMonthDate = new Date(versionDate.getFullYear(), versionDate.getMonth() + 1, 1);
|
||||
const nextVersionMonthDate = new Date(
|
||||
versionDate.getFullYear(),
|
||||
versionDate.getMonth() + 1,
|
||||
1,
|
||||
);
|
||||
|
||||
// Format back to YY.M format
|
||||
const nextVersionYear = nextVersionMonthDate.getFullYear().toString().slice(-2);
|
||||
const nextVersionYear = nextVersionMonthDate
|
||||
.getFullYear()
|
||||
.toString()
|
||||
.slice(-2);
|
||||
const nextVersionMonth = nextVersionMonthDate.getMonth() + 1; // Convert back to 1-indexed
|
||||
|
||||
// Get current date string
|
||||
const currentDate = new Date().toISOString().split('T')[0].replaceAll('-', '');
|
||||
const currentDate = new Date()
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
.replaceAll('-', '');
|
||||
|
||||
switch (values.type) {
|
||||
case 'nightly': {
|
||||
@@ -71,7 +83,9 @@ try {
|
||||
process.exit();
|
||||
}
|
||||
default:
|
||||
console.error('Invalid type specified. Use "nightly", "hotfix", or "monthly".');
|
||||
console.error(
|
||||
'Invalid type specified. Use "nightly", "hotfix", or "monthly".',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
350
.github/scripts/count-points.mjs
vendored
Normal file
350
.github/scripts/count-points.mjs
vendored
Normal file
@@ -0,0 +1,350 @@
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { minimatch } from 'minimatch';
|
||||
|
||||
/** Repository-specific configuration for points calculation */
|
||||
const REPOSITORY_CONFIG = new Map([
|
||||
[
|
||||
'actual',
|
||||
{
|
||||
POINTS_PER_ISSUE_TRIAGE_ACTION: 1,
|
||||
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
|
||||
POINTS_PER_RELEASE_PR: 0,
|
||||
PR_REVIEW_POINT_TIERS: [
|
||||
{ minChanges: 1000, points: 6 },
|
||||
{ minChanges: 100, points: 4 },
|
||||
{ minChanges: 0, points: 2 },
|
||||
],
|
||||
EXCLUDED_FILES: [
|
||||
'yarn.lock',
|
||||
'.yarn/**/*',
|
||||
'packages/component-library/src/icons/**/*',
|
||||
'release-notes/**/*',
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
'docs',
|
||||
{
|
||||
POINTS_PER_ISSUE_TRIAGE_ACTION: 1,
|
||||
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
|
||||
POINTS_PER_RELEASE_PR: 4,
|
||||
PR_REVIEW_POINT_TIERS: [
|
||||
{ minChanges: 1000, points: 6 },
|
||||
{ minChanges: 100, points: 4 },
|
||||
{ minChanges: 0, points: 2 },
|
||||
],
|
||||
EXCLUDED_FILES: ['yarn.lock', '.yarn/**/*'],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Get the start and end dates for the last month.
|
||||
* @returns {Object} An object containing the start and end dates.
|
||||
*/
|
||||
function getLastMonthDates() {
|
||||
// Get data relating to the last month
|
||||
const now = new Date();
|
||||
const firstDayOfLastMonth = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth() - 1,
|
||||
1,
|
||||
);
|
||||
const since = process.env.START_DATE
|
||||
? new Date(process.env.START_DATE)
|
||||
: firstDayOfLastMonth;
|
||||
|
||||
// Calculate the end of the month for the since date
|
||||
const until = new Date(
|
||||
since.getFullYear(),
|
||||
since.getMonth() + 1,
|
||||
0,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
999,
|
||||
);
|
||||
|
||||
return { since, until };
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for calculating the monthly points each core contributor has earned.
|
||||
* These are used for payouts depending.
|
||||
* @param {string} repo - The repository to analyze ('actual' or 'docs')
|
||||
* @returns {number} The total points earned for the repository
|
||||
*/
|
||||
async function countContributorPoints(repo) {
|
||||
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
|
||||
const owner = 'actualbudget';
|
||||
const config = REPOSITORY_CONFIG.get(repo);
|
||||
|
||||
const { since, until } = getLastMonthDates();
|
||||
|
||||
// Get organization members
|
||||
const { data: orgMembers } = await octokit.orgs.listMembers({
|
||||
org: owner,
|
||||
});
|
||||
const orgMemberLogins = new Set(orgMembers.map(member => member.login));
|
||||
|
||||
// Initialize stats map with all org members
|
||||
const stats = new Map(
|
||||
Array.from(orgMemberLogins).map(login => [
|
||||
login,
|
||||
{
|
||||
reviews: [], // Will store objects with PR number and points
|
||||
labelRemovals: [],
|
||||
issueClosings: [],
|
||||
points: 0,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
// Helper function to print statistics
|
||||
const printStats = (title, getValue, formatLine) => {
|
||||
console.log(`\n${title}:`);
|
||||
console.log('='.repeat(title.length + 1));
|
||||
|
||||
const entries = Array.from(stats.entries())
|
||||
.map(([user, userStats]) => [user, getValue(userStats)])
|
||||
.filter(([, count]) => count > 0)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log(`No ${title.toLowerCase()} found in the last month.`);
|
||||
} else {
|
||||
entries.forEach(([user, count]) => {
|
||||
console.log(formatLine(user, count));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get all PRs using search
|
||||
const searchQuery = `repo:${owner}/${repo} is:pr is:merged merged:${since.toISOString()}..${until.toISOString()}`;
|
||||
const recentPRs = await octokit.paginate(
|
||||
octokit.search.issuesAndPullRequests,
|
||||
{
|
||||
q: searchQuery,
|
||||
per_page: 100,
|
||||
advanced_search: true,
|
||||
},
|
||||
response => response.data,
|
||||
);
|
||||
|
||||
// Get reviews and PR details for each PR
|
||||
for (const pr of recentPRs) {
|
||||
const { data: reviews } = await octokit.pulls.listReviews({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
|
||||
// Get list of modified files
|
||||
const { data: modifiedFiles } = await octokit.pulls.listFiles({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
|
||||
// Calculate points based on PR size, excluding specified files
|
||||
const totalChanges = modifiedFiles
|
||||
.filter(
|
||||
file =>
|
||||
!config.EXCLUDED_FILES.some(pattern =>
|
||||
minimatch(file.filename, pattern),
|
||||
),
|
||||
)
|
||||
.reduce((sum, file) => sum + file.additions + file.deletions, 0);
|
||||
|
||||
// Check if this is a release PR
|
||||
const isReleasePR = pr.title.match(/^🔖 \(\d+\.\d+\.\d+\)/);
|
||||
|
||||
// Calculate points for reviewers based on PR size
|
||||
const prPoints = config.PR_REVIEW_POINT_TIERS.find(
|
||||
tier => totalChanges > tier.minChanges,
|
||||
).points;
|
||||
|
||||
// Add points to the reviewers
|
||||
const uniqueReviewers = new Set();
|
||||
reviews
|
||||
.filter(
|
||||
review =>
|
||||
stats.has(review.user?.login) &&
|
||||
review.state === 'APPROVED' &&
|
||||
!uniqueReviewers.has(review.user?.login),
|
||||
)
|
||||
.forEach(({ user: { login: reviewer } }) => {
|
||||
uniqueReviewers.add(reviewer);
|
||||
const userStats = stats.get(reviewer);
|
||||
userStats.reviews.push({ pr: pr.number.toString(), points: prPoints });
|
||||
userStats.points += prPoints;
|
||||
});
|
||||
|
||||
// Award points to the PR creator if it's a release PR
|
||||
if (isReleasePR && stats.has(pr.user.login)) {
|
||||
const creatorStats = stats.get(pr.user.login);
|
||||
creatorStats.reviews.push({
|
||||
pr: pr.number.toString(),
|
||||
points: config.POINTS_PER_RELEASE_PR,
|
||||
isReleaseCreator: true,
|
||||
});
|
||||
creatorStats.points += config.POINTS_PER_RELEASE_PR;
|
||||
}
|
||||
}
|
||||
|
||||
// Get all issues with label events in the last month
|
||||
const issues = await octokit.paginate(
|
||||
octokit.issues.listForRepo,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
state: 'all',
|
||||
sort: 'updated',
|
||||
direction: 'desc',
|
||||
per_page: 100,
|
||||
since: since.toISOString(),
|
||||
},
|
||||
(response, done) =>
|
||||
response.data.filter(issue => new Date(issue.updated_at) <= until),
|
||||
);
|
||||
|
||||
// Get label events for each issue
|
||||
for (const issue of issues) {
|
||||
const { data: events } = await octokit.issues.listEventsForTimeline({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
});
|
||||
|
||||
// Process events
|
||||
events
|
||||
.filter(
|
||||
event =>
|
||||
new Date(event.created_at) > since &&
|
||||
new Date(event.created_at) <= until &&
|
||||
stats.has(event.actor?.login),
|
||||
)
|
||||
.forEach(event => {
|
||||
if (
|
||||
event.event === 'unlabeled' &&
|
||||
event.label &&
|
||||
event.label.name.toLowerCase() === 'needs triage'
|
||||
) {
|
||||
const remover = event.actor.login;
|
||||
const userStats = stats.get(remover);
|
||||
userStats.labelRemovals.push(issue.number.toString());
|
||||
userStats.points += config.POINTS_PER_ISSUE_TRIAGE_ACTION;
|
||||
}
|
||||
|
||||
if (event.event === 'closed') {
|
||||
const closer = event.actor.login;
|
||||
const userStats = stats.get(closer);
|
||||
userStats.issueClosings.push(issue.number.toString());
|
||||
userStats.points += config.POINTS_PER_ISSUE_CLOSING_ACTION;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Print all statistics
|
||||
printStats(
|
||||
`PR Review Statistics (${repo})`,
|
||||
stats => stats.reviews.length,
|
||||
(user, count) =>
|
||||
`${user}: ${count} (PRs: ${stats
|
||||
.get(user)
|
||||
.reviews.map(r => {
|
||||
if (r.isReleaseCreator) {
|
||||
return `#${r.pr} (${r.points}pts - Release Creator)`;
|
||||
}
|
||||
return `#${r.pr} (${r.points}pts)`;
|
||||
})
|
||||
.join(', ')})`,
|
||||
);
|
||||
printStats(
|
||||
`"Needs Triage" Label Removal Statistics (${repo})`,
|
||||
stats => stats.labelRemovals.length,
|
||||
(user, count) =>
|
||||
`${user}: ${count} (Issues: ${stats.get(user).labelRemovals.join(', ')})`,
|
||||
);
|
||||
printStats(
|
||||
`Issue Closing Statistics (${repo})`,
|
||||
stats => stats.issueClosings.length,
|
||||
(user, count) =>
|
||||
`${user}: ${count} (Issues: ${stats.get(user).issueClosings.join(', ')})`,
|
||||
);
|
||||
|
||||
// Print points summary
|
||||
printStats(
|
||||
`Points Summary (${repo})`,
|
||||
stats => stats.points,
|
||||
(user, userPoints) => `${user}: ${userPoints}`,
|
||||
);
|
||||
|
||||
// Calculate and print total points
|
||||
const totalPoints = Array.from(stats.values()).reduce(
|
||||
(sum, userStats) => sum + userStats.points,
|
||||
0,
|
||||
);
|
||||
console.log(`\nTotal points earned for ${repo}: ${totalPoints}`);
|
||||
|
||||
// Return the points
|
||||
return new Map(
|
||||
Array.from(stats.entries()).map(([login, userStats]) => [
|
||||
login,
|
||||
userStats.points,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the points for both repositories and print cumulative results
|
||||
*/
|
||||
async function calculateCumulativePoints() {
|
||||
// Get stats for each repository
|
||||
const repoPointsResults = await Promise.all(
|
||||
Array.from(REPOSITORY_CONFIG.keys()).map(countContributorPoints),
|
||||
);
|
||||
|
||||
// Calculate cumulative stats
|
||||
const cumulativeStats = new Map(repoPointsResults[0]);
|
||||
|
||||
// Combine stats from all repositories
|
||||
for (let i = 1; i < repoPointsResults.length; i++) {
|
||||
for (const [login, points] of repoPointsResults[i].entries()) {
|
||||
if (!cumulativeStats.has(login)) {
|
||||
cumulativeStats.set(login, 0);
|
||||
}
|
||||
|
||||
cumulativeStats.set(login, cumulativeStats.get(login) + points);
|
||||
}
|
||||
}
|
||||
|
||||
// Print cumulative statistics
|
||||
console.log('\n\nCUMULATIVE STATISTICS ACROSS ALL REPOSITORIES');
|
||||
console.log('='.repeat(50));
|
||||
|
||||
console.log('\nCumulative Points Summary:');
|
||||
console.log('='.repeat('Cumulative Points Summary'.length + 1));
|
||||
|
||||
const entries = Array.from(cumulativeStats.entries())
|
||||
.filter(([, count]) => count > 0)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log('No cumulative points summary found.');
|
||||
} else {
|
||||
entries.forEach(([user, points]) => {
|
||||
console.log(`${user}: ${points}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate and print total cumulative points
|
||||
const totalCumulativePoints = Array.from(cumulativeStats.values()).reduce(
|
||||
(sum, points) => sum + points,
|
||||
0,
|
||||
);
|
||||
console.log('\nTotal cumulative points earned: ' + totalCumulativePoints);
|
||||
}
|
||||
|
||||
// Run the calculations
|
||||
calculateCumulativePoints().catch(console.error);
|
||||
26
.github/workflows/count-points.yml
vendored
Normal file
26
.github/workflows/count-points.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: Count points
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run at 00:00 on the first day of every month
|
||||
- cron: '0 0 1 * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
startDate:
|
||||
description: 'Start date for point counter (YYYY-MM-DD)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
count-points:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Count points
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
START_DATE: ${{ inputs.startDate }}
|
||||
run: node .github/scripts/count-points.mjs
|
||||
2
.github/workflows/docker-edge.yml
vendored
2
.github/workflows/docker-edge.yml
vendored
@@ -107,5 +107,3 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
build-args: |
|
||||
GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ name: Extract and upload i18n strings
|
||||
on:
|
||||
schedule:
|
||||
# 4am UTC
|
||||
- cron: "0 4 * * *"
|
||||
- cron: '0 4 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
uses: ./actual/.github/actions/setup
|
||||
with:
|
||||
working-directory: actual
|
||||
download-translations: false # As we'll manually clone instead
|
||||
download-translations: false # As we'll manually clone instead
|
||||
- name: Configure Git config
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
actualbudget/actual
|
||||
|
||||
- name: Unlock translations
|
||||
if: always() # Clean up even on failure
|
||||
if: always() # Clean up even on failure
|
||||
run: |
|
||||
wlc \
|
||||
--url https://hosted.weblate.org/api/ \
|
||||
|
||||
10
.github/workflows/netlify-release.yml
vendored
10
.github/workflows/netlify-release.yml
vendored
@@ -22,15 +22,15 @@ jobs:
|
||||
steps:
|
||||
- name: Repository Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
|
||||
- name: Install Netlify
|
||||
run: npm install netlify-cli@17.10.1 -g
|
||||
|
||||
|
||||
- name: Build Actual
|
||||
run: yarn build:browser
|
||||
run: yarn build:browser
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: netlify_deploy
|
||||
@@ -40,4 +40,4 @@ jobs:
|
||||
--site ${{ secrets.NETLIFY_SITE_ID }} \
|
||||
--auth ${{ secrets.NETLIFY_API_TOKEN }} \
|
||||
--filter @actual-app/web \
|
||||
--prod
|
||||
--prod
|
||||
|
||||
10
.github/workflows/stale.yml
vendored
10
.github/workflows/stale.yml
vendored
@@ -2,7 +2,7 @@ name: 'Close stale PRs'
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
days-before-close: 7
|
||||
close-issue-message: 'This issue has been automatically closed because there have been no comments for 7 days after the "needs info" label was added. If you still need help, please feel free to reopen the issue with the requested information.'
|
||||
remove-stale-when-updated: false
|
||||
stale-pr-message: '' # Disable PR processing
|
||||
close-pr-message: '' # Disable PR processing
|
||||
days-before-pr-stale: -1 # Disable PR processing
|
||||
days-before-pr-close: -1 # Disable PR processing
|
||||
stale-pr-message: '' # Disable PR processing
|
||||
close-pr-message: '' # Disable PR processing
|
||||
days-before-pr-stale: -1 # Disable PR processing
|
||||
days-before-pr-close: -1 # Disable PR processing
|
||||
|
||||
Reference in New Issue
Block a user