Compare commits

..

5 Commits

Author SHA1 Message Date
autofix-ci[bot]
74c27ea698 [autofix.ci] apply automated fixes 2025-09-24 12:11:37 +00:00
coderabbitai[bot]
71d8b0be45 📝 Add docstrings to feat/plugins/plugins-core-package
Docstrings generation was requested by @lelemm.

* https://github.com/actualbudget/actual/pull/5786#issuecomment-3328077622

The following files were modified:

* `packages/plugins-core/src/BasicModalComponents.tsx`
* `packages/plugins-core/src/middleware.ts`
* `packages/plugins-core/src/query/index.ts`
* `packages/plugins-core/src/utils.ts`
2025-09-24 12:07:22 +00:00
github-actions[bot]
0c070e5703 Add release notes for PR #5786 2025-09-24 12:03:28 +00:00
autofix-ci[bot]
c6c1fa686e [autofix.ci] apply automated fixes 2025-09-24 12:02:26 +00:00
lelemm
791a2e63f8 Initial commit for plugins-core 2025-09-24 08:52:46 -03:00
1403 changed files with 20011 additions and 61646 deletions

View File

@@ -0,0 +1,12 @@
---
alwaysApply: true
---
When running yarn commands - always run them in the root directory. Do not run them in child workspaces.
The following commands can be useful:
- `yarn typecheck` to run typechecker
- `yarn lint` to run the code linter and formatter
- `yarn lint:fix` to fix some of the code lint issues (running this is preferred over `yarn lint`)
- `yarn test` to run all the tests

View File

@@ -0,0 +1,37 @@
---
description:
globs: *.ts,*.tsx
alwaysApply: false
---
You are an expert in TypeScript and React.
Code Style and Structure
- Write concise, technical TypeScript code.
- Use functional and declarative programming patterns; avoid classes.
- Prefer iteration and modularization over code duplication.
- Use descriptive variable names with auxiliary verbs (e.g., isLoaded, hasError).
- Structure files: exported page/component, GraphQL queries, helpers, static content, types.
- When creating a new component, place it in its own file rather than grouping multiple components in a single file.
Naming Conventions
- Favor named exports for components and utilities.
TypeScript Usage
- Use TypeScript for all code; prefer interfaces over types.
- Avoid enums; use objects or maps instead.
- Avoid using `any` or `unknown` unless absolutely necessary. Look for type definitions in the codebase instead.
- Avoid type assertions with `as` or `!`; prefer using `satisfies`.
Syntax and Formatting
- Use the "function" keyword for pure functions.
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements.
- Use declarative JSX, keeping JSX minimal and readable.
Change validation
- Run `yarn typecheck` in the root directory to validate that the generated TypeScript code is correct

View File

@@ -0,0 +1,14 @@
---
description:
globs:
alwaysApply: true
---
Vitest test runner is used for unit tests.
When running unit tests, always include the flag `--watch=false` to prevent watch mode.
To run unit tests for a specific package in the monorepo, use the following command:
`yarn workspace <workspaceNameFromPackageJson> run test <pathToTest>`
Recommendation: Minimize the number of dependencies you mock. The fewer dependencies you mock, the better.

View File

@@ -1,3 +0,0 @@
{
"setup-worktree": ["yarn"]
}

View File

@@ -1,69 +0,0 @@
name: 'Documentation'
description: Report documentation issues, request new documentation, or suggest improvements to existing docs.
title: '[Docs] - <title>'
labels: ['documentation']
body:
- type: dropdown
id: issue-type
attributes:
label: 'Issue Type'
description: What type of documentation issue is this?
options:
- New Documentation Request
- Documentation Improvement
- Documentation Bug/Error
- Documentation Change Request
validations:
required: true
- type: textarea
id: description
attributes:
label: 'Description'
description: Please describe the documentation issue, request, or improvement
placeholder: Provide a clear and detailed description...
validations:
required: true
- type: input
id: doc-url
attributes:
label: 'Documentation URL'
description: If this relates to existing documentation, please provide the URL
placeholder: ex. https://actualbudget.org/docs/budgeting/categories or https://github.com/actualbudget/actual/blob/master/packages/docs/...
validations:
required: false
- type: dropdown
id: category
attributes:
label: 'Documentation Category'
description: What category does this relate to?
multiple: true
options:
- Accounts
- Backup & Restore
- Budgeting
- Development
- Installation & Configuration
- Overview
- Reports
- Troubleshooting
- Other
validations:
required: false
- type: textarea
id: expected-behavior
attributes:
label: 'Expected/Desired Content'
description: If applicable, describe what you expect to see or what should be documented
placeholder: What should the documentation say or include?
validations:
required: false
- type: textarea
id: screenshot
attributes:
label: 'Screenshots or Examples'
description: If applicable, add screenshots or examples to help explain your request
value: |
![DESCRIPTION](LINK.png)
render: bash
validations:
required: false

View File

@@ -1,17 +0,0 @@
# check-spelling/check-spelling configuration
| File | Purpose | Format | Info |
| -------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| [dictionary.txt](dictionary.txt) | Replacement dictionary (creating this file will override the default dictionary) | one word per line | [dictionary](https://github.com/check-spelling/check-spelling/wiki/Configuration#dictionary) |
| [allow.txt](allow.txt) | Add words to the dictionary | one word per line (only letters and `'`s allowed) | [allow](https://github.com/check-spelling/check-spelling/wiki/Configuration#allow) |
| [reject.txt](reject.txt) | Remove words from the dictionary (after allow) | grep pattern matching whole dictionary words | [reject](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-reject) |
| [excludes.txt](excludes.txt) | Files to ignore entirely | perl regular expression | [excludes](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-excludes) |
| [only.txt](only.txt) | Only check matching files (applied after excludes) | perl regular expression | [only](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-only) |
| [patterns.txt](patterns.txt) | Patterns to ignore from checked lines | perl regular expression (order matters, first match wins) | [patterns](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-patterns) |
| [candidate.patterns](candidate.patterns) | Patterns that might be worth adding to [patterns.txt](patterns.txt) | perl regular expression with optional comment block introductions (all matches will be suggested) | [candidates](https://github.com/check-spelling/check-spelling/wiki/Feature:-Suggest-patterns) |
| [line_forbidden.patterns](line_forbidden.patterns) | Patterns to flag in checked lines | perl regular expression (order matters, first match wins) | [patterns](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-patterns) |
| [expect.txt](expect.txt) | Expected words that aren't in the dictionary | one word per line (sorted, alphabetically) | [expect](https://github.com/check-spelling/check-spelling/wiki/Configuration#expect) |
| [advice.md](advice.md) | Supplement for GitHub comment when unrecognized words are found | GitHub Markdown | [advice](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-advice) |
Note: you can replace any of these files with a directory by the same name (minus the suffix)
and then include multiple files inside that directory (with that suffix) to merge multiple files together.

View File

@@ -1,24 +0,0 @@
<!-- See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-advice --> <!-- markdownlint-disable MD033 MD041 -->
<details>
<summary>If the flagged items are :exploding_head: false positives</summary>
If items relate to a ...
- binary file (or some other file you wouldn't want to check at all).
Please add a file path to the `excludes.txt` file matching the containing file.
File paths are Perl 5 Regular Expressions - you can [test](https://www.regexplanet.com/advanced/perl/) yours before committing to verify it will match your files.
`^` refers to the file's path from the root of the repository, so `^README\.md$` would exclude [README.md](../tree/HEAD/README.md) (on whichever branch you're using).
- well-formed pattern.
If you can write a [pattern](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-patterns) that would match it,
try adding it to the `patterns.txt` file.
Patterns are Perl 5 Regular Expressions - you can [test](https://www.regexplanet.com/advanced/perl/) yours before committing to verify it will match your lines.
Note that patterns can't match multiline strings.
</details>

View File

@@ -1,8 +0,0 @@
trevdor
Farlow
Matiss
Aboltins
jlongster
howell
evequefou
Fiddaman

View File

@@ -1,144 +0,0 @@
ABANCA
actualbudget
addtransactions
Akahu
AMZN
Andelskassen
AQL
Authelia
autocompletes
Blix
bnp
BSCHESMM
BTC
CAGLESMM
Caju
caniuse
Cardless
CAROOT
categorygroup
Cembra
Certbot
CLI
clickable
clsx
codemirror
Coinbase
commandlet
Coverflex
Crd
crdt
creditcards
crowdsourced
debian
dedupes
deleteaccount
DKB
dmg
easybank
Edenred
Coverfelx
emojis
emoji
escodegen
EUR
expando
Firefox
flyctl
Formik
Fortuneo
gebabebb
GEBABEBB
Greenshot
HSA
htpasswd
IBANs
iex
importtransactions
ING
invokable
iwr
jointaccounts
jwl
KBC
kcab
keyout
KREDBEBB
Kroger
kubectl
kubernetes
ldaplogin
letsencrypt
libofx
linting
Linuxes
linuxsvg
lleskassen
lte
mac
macsvg
Mariushosting
minimalistic
monkeypatch
Monobank
Morrisons
NAIAGB
NDEADKKK
Netflix
netlify
Nordea
NORDEA
nordigen
notlike
NRNBGB
nynab
offbudget
ofx
OFX
oneof
payeerule
pikaday
pikapods
playsinline
portalization
Postgresql
protobuf
publix
QFX
QIF
Quicken
returnsandreimbursements
Rezip
roadmap
RUpdate
sankey
SANTANDER
screenshots
SEB
subfolders
subreaper
subtransaction
subtransactions
Suisse
Sztup
tini
traefik
Trafico
Trumf
Upstash
useb
usernames
valign
Venmo
Weblate
winsvg
WSL
Xxxxx
ynab
Ynab
YNAB
ZKB
Zsolt
IDBy
isapprox
isbetween

View File

@@ -1,76 +0,0 @@
# See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-excludes
(?:^|/)(?i)COPYRIGHT
(?:^|/)(?i)LICEN[CS]E
(?:^|/)3rdparty/
(?:^|/)go\.sum$
(?:^|/)package(?:-lock|)\.json$
(?:^|/)pyproject.toml
(?:^|/)requirements(?:-dev|-doc|-test|)\.txt$
(?:^|/)vendor/
ignore$
\.a$
\.ai$
\.avi$
\.bmp$
\.bz2$
\.class$
\.coveragerc$
\.crt$
\.css$
\.dll$
\.docx?$
\.drawio$
\.DS_Store$
\.eot$
\.exe$
\.gif$
\.git-blame-ignore-revs$
\.gitattributes$
\.graffle$
\.gz$
\.icns$
\.ico$
\.jar$
\.jks$
\.jpe?g$
\.key$
\.lib$
\.lock$
\.map$
\.min\..
\.mod$
\.mp[34]$
\.o$
\.ocf$
\.otf$
\.pdf$
\.pem$
\.png$
\.psd$
\.pyc$
\.pylintrc$
\.s$
\.svgz?$
\.tar$
\.tiff?$
\.ttf$
\.wav$
\.webm$
\.webp$
\.woff2?$
\.xlsx?$
\.zip$
^\.github/actions/spelling/
^\.github/ISSUE_TEMPLATE/
^\Q.github/workflows/spelling.yml\E$
^\.yarn/
^\Qnode_modules/\E$
^\Qsrc/\E$
^\Qstatic/\E$
^\Q.github/\E$
(?:^|/)package(?:-lock|)\.json$
(?:^|/)yarn\.lock$
(?:^|/)(?i)docusaurus.config.js
(?:^|/)(?i)README.md
^\static/
\.tsx$

View File

@@ -1,152 +0,0 @@
Abanca
ABNAMRO
ABNANL
Activo
AESUDEF
ALZEY
Anglais
aql
AUR
Authentik
BANKA
BANKINTER
BAWAATWW
Belfius
Biedenkopf
BIGBPLPW
Bizum
BKBKESMM
BOFIIE
Bourso
Boursobank
Boursorama
BPER
BPMOIT
brexplpw
BYLADEM
Caddyfile
CAGLPTPL
Caixa
CAMT
cashflow
Cetelem
cimode
Citi
Citibank
Cloudflare
CMCIFRPAXXX
COBADEFF
CODEOWNERS
commerzbank
Copiar
CREGBEBB
crt
Danske
datadir
Depositos
DIREKT
Dockerfiles
Dominguez
DUSSDEDDXXX
DUSSELDORF
ENTERCARD
Entra
EUA
Eurocard
fidd
Fineco
Finicity
Fintro
Finverse
Flathub
FORTUNEO
FTNOFRP
Gemeinschaftsbank
Geral
gernes
Globecard
GLS
gocardless
Grafana
HABAL
Hampel
HELADEF
INDUSTRIEL
INGBPLPW
Ingo
INR
Intesa
INVSTMTMSGSRS
ISYBANK
ITBBITMM
jfdoming
JMD
KBCBE
Keycloak
Khurozov
KORT
Kreditbank
lage
LHV
LHVBEE
LKR
mbank
mdc
modals
Moldovan
murmurhash
nginx
OIDC
overbudgeted
overbudgeting
Paribas
passwordless
pluggyai
Poste
PPABPLPK
prefs
Primoco
Priotecs
proactively
pwa
Qatari
QNTOFRP
QONTO
Raiffeisen
revolut
RIED
RSchedule
RSD
SEK
simplefin
SKHSFI
Sparkasse
SPK
sseldorf
SSK
Stadtsparkasse
statestore
SUBASKBX
SVGR
swc
SWEDBANK
SWEDNOKK
Synology
systemctl
tada
taskbar
templating
THB
touchscreen
triaging
UAH
ubuntu
userinfo
Userscripts
UZS
vrt
VUB
websecure
Widiba
WOR
youngcw

View File

@@ -1,62 +0,0 @@
# reject `m_data` as there's a certain OS which has evil defines that break things if it's used elsewhere
# \bm_data\b
# If you have a framework that uses `it()` for testing and `fit()` for debugging a specific test,
# you might not want to check in code where you were debugging w/ `fit()`, in which case, you might want
# to use this:
#\bfit\(
# s.b. GitHub
#\bGithub\b
# s.b. GitLab
\bGitlab\b
# s.b. JavaScript
\bJavascript\b
# s.b. Microsoft
\bMicroSoft\b
# s.b. another
\ban[- ]other\b
# s.b. greater than
\bgreater then\b
# s.b. into
#\sin to\s
# s.b. opt-in
\sopt in\s
# s.b. less than
\bless then\b
# s.b. otherwise
\bother[- ]wise\b
# s.b. nonexistent
\bnon existing\b
\b[Nn]o[nt][- ]existent\b
# s.b. preexisting
[Pp]re[- ]existing
# s.b. preempt
[Pp]re[- ]empt\b
# s.b. preemptively
[Pp]re[- ]emptively
# s.b. reentrancy
[Rr]e[- ]entrancy
# s.b. reentrant
[Rr]e[- ]entrant
# s.b. workaround(s)
\bwork[- ]arounds?\b
# Reject duplicate words
\s([A-Z]{3,}|[A-Z][a-z]{2,}|[a-z]{3,})\s\g{-1}\s

View File

@@ -1,3 +0,0 @@
# Only check files in the packages/docs directory
^packages/docs/

View File

@@ -1,81 +0,0 @@
# See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-patterns
# Questionably acceptable forms of `in to`
# Personally, I prefer `log into`, but people object
# https://www.tprteaching.com/log-into-log-in-to-login/
\b[Ll]og in to\b
# acceptable duplicates
# ls directory listings
[-bcdlpsw](?:[-r][-w][-Ssx]){3}\s+\d+\s+\S+\s+\S+\s+\d+\s+
# C types and repeated CSS values
\s(center|div|inherit|long|LONG|none|normal|solid|thin|transparent|very)(?: \g{-1})+\s
# go templates
\s(\w+)\s+\g{-1}\s+\`(?:graphql|json|yaml):
# javadoc / .net
(?:[\\@](?:groupname|param)|(?:public|private)(?:\s+static|\s+readonly)*)\s+(\w+)\s+\g{-1}\s
# Commit message -- Signed-off-by and friends
^\s*(?:(?:Based-on-patch|Co-authored|Helped|Mentored|Reported|Reviewed|Signed-off)-by|Thanks-to): (?:[^<]*<[^>]*>|[^<]*)\s*$
# Autogenerated revert commit message
^This reverts commit [0-9a-f]{40}\.$
# ignore long runs of a single character:
\b([A-Za-z])\g{-1}{3,}\b
# Automatically suggested patterns
# hit-count: 1255 file-count: 51
# https/http/file urls
(?:\b(?:https?|ftp|file)://)[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]
# hit-count: 1174 file-count: 33
# GitHub SHAs (markdown)
(?:\[`?[0-9a-f]+`?\]\(https:/|)/(?:www\.|)github\.com(?:/[^/\s"]+){2,}(?:/[^/\s")]+)(?:[0-9a-f]+(?:[-0-9a-zA-Z/#.]*|)\b|)
# hit-count: 6 file-count: 4
# version suffix <word>v#
(?:(?<=[A-Z]{2})V|(?<=[a-z]{2}|[A-Z]{2})v)\d+(?:\b|(?=[a-zA-Z_]))
# hit-count: 6 file-count: 2
# URL escaped characters
\%[0-9A-F][A-F]
# hit-count: 5 file-count: 4
# hex runs
\b[0-9a-fA-F]{16,}\b
# hit-count: 4 file-count: 2
# uuid:
\b[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}\b
# hit-count: 3 file-count: 2
# discord
/discord(?:app\.com|\.gg)/(?:invite/)?[a-zA-Z0-9]{7,}
# hit-count: 2 file-count: 2
# Contributor
\[[^\]]+\]\(https://github\.com/[^/\s"]+\)
@[^$\W]*-?\w+
# hit-count: 1 file-count: 1
# While you could try to match `http://` and `https://` by using `s?` in `https?://`, sometimes there
# YouTube url
\b(?:(?:www\.|)youtube\.com|youtu.be)/(?:channel/|embed/|user/|playlist\?list=|watch\?v=|v/|)[-a-zA-Z0-9?&=_%]*
# hit-count: 1 file-count: 1
# Google Fonts
\bfonts\.(?:googleapis|gstatic)\.com/[-/?=:;+&0-9a-zA-Z]*
# hit-count: 1 file-count: 1
# hex digits including css/html color classes:
(?:[\\0][xX]|\\u|[uU]\+|#x?|\%23)[0-9_a-fA-FgGrR]*?[a-fA-FgGrR]{2,}[0-9_a-fA-FgGrR]*(?:[uUlL]{0,3}|u\d+)\b
# docusaurus image paths, URLs
[-a-zA-Z0-9@:%_\+.~#?&//=]{2,256}\.[a-z]{2,4}\b(\/[-a-zA-Z0-9@:%_\+.~#?&//=]*)?
# eliminate words like [`nvm`] or [`asdf`] or [heidiSQL] without backquotes
\[.+?]
# allowlist specific non-English words with non-ASCII characters
\b(Länsförsäkringar|München|Złoty)\b

View File

@@ -1,10 +0,0 @@
^attache$
benefitting
occurences?
^dependan.*
^oer$
Sorce
^[Ss]pae.*
^untill$
^untilling$
^wether.*

View File

@@ -15,9 +15,9 @@ runs:
using: composite
steps:
- name: Install node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 20
- name: Install yarn
run: npm install -g yarn
shell: bash
@@ -27,28 +27,18 @@ runs:
run: echo "version=$(node -v)" >> "$GITHUB_OUTPUT"
shell: bash
- name: Cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@v4
id: cache
with:
path: ${{ format('{0}/**/node_modules', inputs.working-directory) }}
key: yarn-v1-${{ runner.os }}-${{ steps.get-node.outputs.version }}-${{ hashFiles(format('{0}/**/yarn.lock', inputs.working-directory)) }}
- name: Ensure Lage cache directory exists
run: mkdir -p ${{ format('{0}/.lage', inputs.working-directory) }}
shell: bash
- name: Cache Lage
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ format('{0}/.lage', inputs.working-directory) }}
key: lage-${{ runner.os }}-${{ github.sha }}
restore-keys: |
lage-${{ runner.os }}-
- name: Install
working-directory: ${{ inputs.working-directory }}
run: yarn --immutable
shell: bash
if: steps.cache.outputs.cache-hit != 'true'
- name: Download translations
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@v4
with:
repository: actualbudget/translations
path: ${{ inputs.working-directory }}/packages/desktop-client/locale

View File

@@ -2,34 +2,45 @@ import { Octokit } from '@octokit/rest';
import { minimatch } from 'minimatch';
import pLimit from 'p-limit';
const limit = pLimit(50);
const limit = pLimit(30);
const CONFIG = {
POINTS_PER_ISSUE_TRIAGE_ACTION: 1,
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
POINTS_PER_RELEASE_PR: 4, // Awarded to whoever merges the release PR
// Point tiers for code changes (non-docs)
CODE_PR_REVIEW_POINT_TIERS: [
{ minChanges: 500, points: 8 },
{ minChanges: 100, points: 6 },
{ minChanges: 10, points: 2 },
{ minChanges: 0, points: 1 },
/** 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: 500, points: 8 },
{ minChanges: 100, points: 6 },
{ minChanges: 10, points: 2 },
{ minChanges: 0, points: 1 },
],
EXCLUDED_FILES: [
'yarn.lock',
'.yarn/**/*',
'packages/component-library/src/icons/**/*',
'release-notes/**/*',
],
},
],
// Point tiers for docs changes (packages/docs/**)
DOCS_PR_REVIEW_POINT_TIERS: [
{ minChanges: 2000, points: 6 },
{ minChanges: 200, points: 4 },
{ minChanges: 0, points: 2 },
[
'docs',
{
POINTS_PER_ISSUE_TRIAGE_ACTION: 1,
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
POINTS_PER_RELEASE_PR: 4,
PR_REVIEW_POINT_TIERS: [
{ minChanges: 2000, points: 6 },
{ minChanges: 200, points: 4 },
{ minChanges: 0, points: 2 },
],
EXCLUDED_FILES: ['yarn.lock', '.yarn/**/*'],
},
],
EXCLUDED_FILES: [
'yarn.lock',
'.yarn/**/*',
'packages/component-library/src/icons/**/*',
'release-notes/**/*',
'upcoming-release-notes/**/*',
],
DOCS_FILES_PATTERN: 'packages/docs/**/*',
};
]);
/**
* Get the start and end dates for the last month.
@@ -65,14 +76,15 @@ function getLastMonthDates() {
/**
* Used for calculating the monthly points each core contributor has earned.
* These are used for payouts depending.
* @returns {Map} A map of contributor logins to their total points earned
* @param {string} repo - The repository to analyze ('actual' or 'docs')
* @returns {number} The total points earned for the repository
*/
async function countContributorPoints() {
async function countContributorPoints(repo) {
const octokit = new Octokit({
auth: process.env.GITHUB_TOKEN,
});
const owner = 'actualbudget';
const repo = 'actual';
const config = REPOSITORY_CONFIG.get(repo);
const { since, until } = getLastMonthDates();
@@ -87,8 +99,7 @@ async function countContributorPoints() {
Array.from(orgMemberLogins).map(login => [
login,
{
codeReviews: [], // Will store objects with PR number and points for main repo changes
docsReviews: [], // Will store objects with PR number and points for docs changes
reviews: [], // Will store objects with PR number and points
labelRemovals: [],
issueClosings: [],
points: 0,
@@ -118,13 +129,13 @@ async function countContributorPoints() {
// Get all PRs using search
const searchQuery = `repo:${owner}/${repo} is:pr is:merged merged:${since.toISOString()}..${until.toISOString()}`;
const recentPRs = await octokit.paginate(
'GET /search/issues',
octokit.search.issuesAndPullRequests,
{
q: searchQuery,
per_page: 100,
advanced_search: true,
},
response => response.data.filter(pr => pr.number),
response => response.data,
);
// Get reviews and PR details for each PR
@@ -145,91 +156,48 @@ async function countContributorPoints() {
),
]);
const filteredFiles = modifiedFiles.filter(
file =>
!CONFIG.EXCLUDED_FILES.some(pattern =>
minimatch(file.filename, pattern, { dot: true }),
),
);
const docsFiles = filteredFiles.filter(file =>
minimatch(file.filename, CONFIG.DOCS_FILES_PATTERN, { dot: true }),
);
const codeFiles = filteredFiles.filter(
file =>
!minimatch(file.filename, CONFIG.DOCS_FILES_PATTERN, { dot: true }),
);
const docsChanges = docsFiles.reduce(
(sum, file) => sum + file.additions + file.deletions,
0,
);
const codeChanges = codeFiles.reduce(
(sum, file) => sum + file.additions + file.deletions,
0,
);
const docsPoints =
docsChanges > 0
? (CONFIG.DOCS_PR_REVIEW_POINT_TIERS.find(
t => docsChanges >= t.minChanges,
)?.points ?? 0)
: 0;
const codePoints =
codeChanges > 0 || docsChanges === 0
? (CONFIG.CODE_PR_REVIEW_POINT_TIERS.find(
t => codeChanges >= t.minChanges,
)?.points ?? 0)
: 0;
const totalChanges = modifiedFiles
.filter(
file =>
!config.EXCLUDED_FILES.some(pattern =>
minimatch(file.filename, pattern),
),
)
.reduce((sum, file) => sum + file.additions + file.deletions, 0);
const isReleasePR = pr.title.match(/🔖.*\d+\.\d+\.\d+/);
const prPoints =
config.PR_REVIEW_POINT_TIERS.find(t => totalChanges >= t.minChanges)
?.points ?? 0;
if (isReleasePR) {
// release PRs are created by the github-actions bot so we attribute points to the merger
const { data: prDetails } = await octokit.pulls.get({
owner,
repo,
pull_number: pr.number,
});
if (prDetails.merged_by && stats.has(prDetails.merged_by.login)) {
const mergerStats = stats.get(prDetails.merged_by.login);
mergerStats.codeReviews.push({
if (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,
isReleaseMerger: true,
points: config.POINTS_PER_RELEASE_PR,
isReleaseCreator: true,
});
mergerStats.points += CONFIG.POINTS_PER_RELEASE_PR;
creatorStats.points += config.POINTS_PER_RELEASE_PR;
}
} else {
const uniqueReviewers = new Set();
reviews.data.forEach(review => {
if (
review.state === 'APPROVED' &&
stats.has(review.user?.login) &&
!uniqueReviewers.has(review.user?.login)
) {
const reviewer = review.user.login;
reviews.data
.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);
if (docsPoints > 0) {
userStats.docsReviews.push({
pr: pr.number.toString(),
points: docsPoints,
});
userStats.points += docsPoints;
}
if (codePoints > 0) {
userStats.codeReviews.push({
pr: pr.number.toString(),
points: codePoints,
});
userStats.points += codePoints;
}
}
});
userStats.reviews.push({
pr: pr.number.toString(),
points: prPoints,
});
userStats.points += prPoints;
});
}
}),
),
@@ -273,7 +241,7 @@ async function countContributorPoints() {
const remover = event.actor.login;
const userStats = stats.get(remover);
userStats.labelRemovals.push(issue.number.toString());
userStats.points += CONFIG.POINTS_PER_ISSUE_TRIAGE_ACTION;
userStats.points += config.POINTS_PER_ISSUE_TRIAGE_ACTION;
}
if (
@@ -283,7 +251,7 @@ async function countContributorPoints() {
const closer = event.actor.login;
const userStats = stats.get(closer);
userStats.issueClosings.push(issue.number.toString());
userStats.points += CONFIG.POINTS_PER_ISSUE_CLOSING_ACTION;
userStats.points += config.POINTS_PER_ISSUE_CLOSING_ACTION;
}
});
}),
@@ -292,39 +260,27 @@ async function countContributorPoints() {
// Print all statistics
printStats(
'Code Review Statistics',
stats => stats.codeReviews.length,
`PR Review Statistics (${repo})`,
stats => stats.reviews.length,
(user, count) =>
`${user}: ${count} (PRs: ${stats
.get(user)
.codeReviews.map(r => {
if (r.isReleaseMerger) {
return `#${r.pr} (${r.points}pts - Release Merger)`;
.reviews.map(r => {
if (r.isReleaseCreator) {
return `#${r.pr} (${r.points}pts - Release Creator)`;
}
return `#${r.pr} (${r.points}pts)`;
})
.join(', ')})`,
);
printStats(
'Docs Review Statistics',
stats => stats.docsReviews.length,
(user, count) =>
`${user}: ${count} (PRs: ${stats
.get(user)
.docsReviews.map(r => `#${r.pr} (${r.points}pts)`)
.join(', ')})`,
);
printStats(
'"Needs Triage" Label Removal Statistics',
`"Needs Triage" Label Removal Statistics (${repo})`,
stats => stats.labelRemovals.length,
(user, count) =>
`${user}: ${count} (Issues: ${stats.get(user).labelRemovals.join(', ')})`,
);
printStats(
'Issue Closing Statistics',
`Issue Closing Statistics (${repo})`,
stats => stats.issueClosings.length,
(user, count) =>
`${user}: ${count} (Issues: ${stats.get(user).issueClosings.join(', ')})`,
@@ -332,7 +288,7 @@ async function countContributorPoints() {
// Print points summary
printStats(
'Points Summary',
`Points Summary (${repo})`,
stats => stats.points,
(user, userPoints) => `${user}: ${userPoints}`,
);
@@ -342,7 +298,7 @@ async function countContributorPoints() {
(sum, userStats) => sum + userStats.points,
0,
);
console.log(`\nTotal points earned: ${totalPoints}`);
console.log(`\nTotal points earned for ${repo}: ${totalPoints}`);
// Return the points
return new Map(
@@ -353,5 +309,55 @@ async function countContributorPoints() {
);
}
/**
* 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
countContributorPoints().catch(console.error);
calculateCumulativePoints().catch(console.error);

View File

@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup

View File

@@ -15,7 +15,7 @@ jobs:
autofix:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Format code

View File

@@ -12,7 +12,6 @@ on:
branches:
- master
pull_request:
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -22,61 +21,66 @@ jobs:
api:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Build API
run: cd packages/api && yarn build
- name: Create package tgz
run: cd packages/api && yarn pack && mv package.tgz actual-api.tgz
- name: Prepare bundle stats artifact
run: cp packages/api/app/stats.json api-stats.json
- name: Upload Build
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@v4
with:
name: actual-api
path: packages/api/actual-api.tgz
- name: Upload API bundle stats
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: api-build-stats
path: api-stats.json
crdt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Build CRDT
run: cd packages/crdt && yarn build
- name: Create package tgz
run: cd packages/crdt && yarn pack && mv package.tgz actual-crdt.tgz
- name: Upload Build
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@v4
with:
name: actual-crdt
path: packages/crdt/actual-crdt.tgz
plugins-core:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Plugins Core
run: yarn workspace @actual-app/plugins-core build
- name: Create package tgz
run: cd packages/plugins-core && yarn pack && mv package.tgz actual-plugins-core.tgz
- name: Upload Build
uses: actions/upload-artifact@v4
with:
name: actual-plugins-core
path: packages/plugins-core/actual-plugins-core.tgz
web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Web
run: yarn build:browser
- name: Upload Build
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@v4
with:
name: actual-web
path: packages/desktop-client/build
- name: Upload Build Stats
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@v4
with:
name: build-stats
path: packages/desktop-client/build-stats
@@ -84,15 +88,13 @@ jobs:
server:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Build Server
run: yarn workspace @actual-app/sync-server build
- name: Upload Build
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@v4
with:
name: sync-server
path: packages/sync-server/build

View File

@@ -5,7 +5,6 @@ on:
branches:
- master
pull_request:
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -15,31 +14,25 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Lint
run: yarn lint
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Typecheck
run: yarn typecheck
validate-cli:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Build Web
run: yarn build:server
- name: Check that the built CLI works
@@ -47,11 +40,9 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Test
run: yarn test
@@ -59,9 +50,9 @@ jobs:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 20
- name: Check migrations
run: node ./.github/actions/check-migrations.js

View File

@@ -22,7 +22,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Count points

View File

@@ -1,16 +1,21 @@
name: Build Edge Docker Image
# Edge Docker images are built for every push to master
# Edge Docker images are built for every commit, and daily
on:
push:
branches:
- master
paths:
- 'packages/sync-server/**'
pull_request:
branches:
- master
paths:
- 'packages/sync-server/**'
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
concurrency:
group: docker-edge-build
cancel-in-progress: true
permissions:
contents: read
packages: write
@@ -36,17 +41,17 @@ jobs:
matrix:
os: [ubuntu, alpine]
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
uses: docker/metadata-action@v5
with:
# Push to both Docker Hub and Github Container Registry
images: ${{ env.IMAGES }}
@@ -54,14 +59,14 @@ jobs:
tags: ${{ env.TAGS }}
- name: Login to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@v3
if: github.event_name != 'pull_request' && !github.event.repository.fork
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@v3
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
@@ -76,7 +81,7 @@ jobs:
run: yarn build:server
- name: Build image for testing
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@v5
with:
context: .
push: false
@@ -93,7 +98,7 @@ jobs:
# This will use the cache from the earlier build step and not rebuild the image
# https://docs.docker.com/build/ci/github-actions/test-before-push/
- name: Build and push images
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}

View File

@@ -28,17 +28,17 @@ jobs:
name: Build Docker image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
uses: docker/metadata-action@v5
with:
# Push to both Docker Hub and Github Container Registry
images: ${{ env.IMAGES }}
@@ -48,7 +48,7 @@ jobs:
- name: Docker meta for Alpine image
id: alpine-meta
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGES }}
# Automatically update :latest
@@ -58,13 +58,13 @@ jobs:
tags: ${{ env.TAGS }}
- name: Login to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -78,7 +78,7 @@ jobs:
run: yarn build:server
- name: Build and push ubuntu image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@v5
with:
context: .
push: true
@@ -87,7 +87,7 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
- name: Build and push alpine image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@v5
with:
context: .
push: true

View File

@@ -1,164 +0,0 @@
name: Check Spelling (Docs)
# Comment management is handled through a secondary job, for details see:
# https://github.com/check-spelling/check-spelling/wiki/Feature%3A-Restricted-Permissions
#
# `jobs.comment-push` runs when a push is made to a repository and the `jobs.spelling` job needs to make a comment
# (in odd cases, it might actually run just to collapse a comment, but that's fairly rare)
# it needs `contents: write` in order to add a comment.
#
# `jobs.comment-pr` runs when a pull_request is made to a repository and the `jobs.spelling` job needs to make a comment
# or collapse a comment (in the case where it had previously made a comment and now no longer needs to show a comment)
# it needs `pull-requests: write` in order to manipulate those comments.
# Updating pull request branches is managed via comment handling.
# For details, see: https://github.com/check-spelling/check-spelling/wiki/Feature:-Update-expect-list
#
# These elements work together to make it happen:
#
# `on.issue_comment`
# This event listens to comments by users asking to update the metadata.
#
# `jobs.update`
# This job runs in response to an issue_comment and will push a new commit
# to update the spelling metadata.
#
# `with.experimental_apply_changes_via_bot`
# Tells the action to support and generate messages that enable it
# to make a commit to update the spelling metadata.
#
# `with.ssh_key`
# In order to trigger workflows when the commit is made, you can provide a
# secret (typically, a write-enabled github deploy key).
#
# For background, see: https://github.com/check-spelling/check-spelling/wiki/Feature:-Update-with-deploy-key
on:
push:
branches:
- '**'
tags-ignore:
- '**'
paths:
- 'packages/docs/**'
- '.github/workflows/docs-spelling.yml'
- '.github/actions/docs-spelling/**'
pull_request_target:
branches:
- '**'
tags-ignore:
- '**'
paths:
- 'packages/docs/**'
- '.github/workflows/docs-spelling.yml'
- '.github/actions/docs-spelling/**'
types:
- 'opened'
- 'reopened'
- 'synchronize'
issue_comment:
types:
- 'created'
jobs:
spelling:
name: Check Spelling
permissions:
contents: read
pull-requests: read
actions: read
security-events: write
outputs:
followup: ${{ steps.spelling.outputs.followup }}
runs-on: ubuntu-latest
if: "contains(github.event_name, 'pull_request') || github.event_name == 'push'"
concurrency:
group: spelling-${{ github.event.pull_request.number || github.ref }}
# note: If you use only_check_changed_files, you do not want cancel-in-progress
cancel-in-progress: true
steps:
- name: check-spelling
id: spelling
uses: check-spelling/check-spelling@main
with:
suppress_push_for_open_pull_request: 1
checkout: true
check_file_names: 1
spell_check_this: check-spelling/spell-check-this@prerelease
post_comment: 0
use_magic_file: 1
experimental_apply_changes_via_bot: 1
use_sarif: 1
extra_dictionary_limit: 12
check_extra_dictionaries: ''
extra_dictionaries: cspell:cpp/src/cpp.txt
cspell:software-terms/src/software-terms.txt
cspell:python/src/python/python-lib.txt
cspell:node/node.txt
cspell:filetypes/filetypes.txt
cspell:aws/aws.txt
cspell:typescript/dict/typescript.txt
cspell:npm/dict/npm.txt
cspell:fullstack/dict/fullstack.txt
cspell:html/dict/html.txt
cspell:css/dict/css.txt
config: .github/actions/docs-spelling
comment-push:
name: Report (Push)
# If your workflow isn't running on push, you can remove this job
runs-on: ubuntu-latest
needs: spelling
permissions:
contents: write
if: (success() || failure()) && needs.spelling.outputs.followup && github.event_name == 'push'
steps:
- name: comment
uses: check-spelling/check-spelling@main
with:
checkout: true
spell_check_this: check-spelling/spell-check-this@prerelease
task: ${{ needs.spelling.outputs.followup }}
config: .github/actions/docs-spelling
comment-pr:
name: Report (PR)
# If you workflow isn't running on pull_request*, you can remove this job
runs-on: ubuntu-latest
needs: spelling
permissions:
pull-requests: write
if: (success() || failure()) && needs.spelling.outputs.followup && contains(github.event_name, 'pull_request')
steps:
- name: comment
uses: check-spelling/check-spelling@main
with:
checkout: true
spell_check_this: check-spelling/spell-check-this@prerelease
task: ${{ needs.spelling.outputs.followup }}
experimental_apply_changes_via_bot: 1
config: .github/actions/docs-spelling
update:
name: Update PR
permissions:
contents: write
pull-requests: write
actions: read
runs-on: ubuntu-latest
if: ${{
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '@check-spelling-bot apply')
}}
concurrency:
group: spelling-update-${{ github.event.issue.number }}
cancel-in-progress: false
steps:
- name: apply spelling updates
uses: check-spelling/check-spelling@main
with:
experimental_apply_changes_via_bot: 1
checkout: true
ssh_key: '${{ secrets.CHECK_SPELLING }}'
config: .github/actions/docs-spelling

View File

@@ -17,7 +17,7 @@ jobs:
outputs:
netlify_url: ${{ steps.netlify.outputs.url }}
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Wait for Netlify build to finish
@@ -32,18 +32,16 @@ jobs:
needs: netlify
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.56.0-jammy
image: mcr.microsoft.com/playwright:v1.52.0-jammy
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Trust the repository directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Run E2E Tests on Netlify URL
run: yarn e2e
env:
E2E_START_URL: ${{ needs.netlify.outputs.netlify_url }}
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
- uses: actions/upload-artifact@v4
if: always()
with:
name: desktop-client-test-results
@@ -55,17 +53,15 @@ jobs:
name: Functional Desktop App
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.56.0-jammy
image: mcr.microsoft.com/playwright:v1.52.0-jammy
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Trust the repository directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Run Desktop app E2E Tests
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
- uses: actions/upload-artifact@v4
if: always()
with:
name: desktop-app-test-results
@@ -78,16 +74,16 @@ jobs:
needs: netlify
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.56.0-jammy
image: mcr.microsoft.com/playwright:v1.52.0-jammy
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Run VRT Tests on Netlify URL
run: yarn vrt
env:
E2E_START_URL: ${{ needs.netlify.outputs.netlify_url }}
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
- uses: actions/upload-artifact@v4
if: always()
with:
name: desktop-client-test-results

View File

@@ -24,12 +24,12 @@ jobs:
strategy:
matrix:
os:
- ubuntu-22.04
- ubuntu-latest
- windows-latest
- macos-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools
- if: ${{ ! startsWith(matrix.os, 'windows') }}
@@ -44,9 +44,9 @@ jobs:
sudo apt-get install flatpak -y
sudo apt-get install flatpak-builder -y
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
sudo flatpak install org.freedesktop.Sdk//24.08 -y
sudo flatpak install org.freedesktop.Platform//24.08 -y
sudo flatpak install org.electronjs.Electron2.BaseApp//24.08 -y
sudo flatpak install org.freedesktop.Sdk/x86_64/23.08 -y
sudo flatpak install org.freedesktop.Platform/x86_64/23.08 -y
sudo flatpak install org.electronjs.Electron2.BaseApp/x86_64/23.08 -y
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Electron for Mac
@@ -57,12 +57,11 @@ jobs:
CSC_LINK: ${{ secrets.CSC_LINK }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Build Electron
if: ${{ ! startsWith(matrix.os, 'macos') }}
run: ./bin/package-electron
- name: Upload Build
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@v4
with:
name: actual-electron-${{ matrix.os }}
path: |
@@ -73,7 +72,7 @@ jobs:
packages/desktop-electron/dist/*.flatpak
- name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@v4
with:
name: actual-electron-${{ matrix.os }}-appx
path: |
@@ -83,7 +82,7 @@ jobs:
run: |
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Add to new release
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
uses: softprops/action-gh-release@v2
with:
draft: true
body: |
@@ -113,7 +112,7 @@ jobs:
Install-Module -Name StoreBroker -AcceptLicense -Force -Scope CurrentUser -Verbose
- name: Download Microsoft Store artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@v4
with:
name: actual-electron-windows-latest-appx

View File

@@ -19,12 +19,12 @@ jobs:
strategy:
matrix:
os:
- ubuntu-22.04
- ubuntu-latest
- windows-latest
- macos-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools
- if: ${{ ! startsWith(matrix.os, 'windows') }}
@@ -39,73 +39,26 @@ jobs:
sudo apt-get install flatpak -y
sudo apt-get install flatpak-builder -y
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
sudo flatpak install org.freedesktop.Sdk//24.08 -y
sudo flatpak install org.freedesktop.Platform//24.08 -y
sudo flatpak install org.electronjs.Electron2.BaseApp//24.08 -y
sudo flatpak install org.freedesktop.Sdk/x86_64/23.08 -y
sudo flatpak install org.freedesktop.Platform/x86_64/23.08 -y
sudo flatpak install org.electronjs.Electron2.BaseApp/x86_64/23.08 -y
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Electron
run: ./bin/package-electron
- name: Upload Linux x64 AppImage
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
- name: Upload Build
uses: actions/upload-artifact@v4
with:
name: Actual-linux-x86_64.AppImage
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-x86_64.AppImage
- name: Upload Linux arm64 AppImage
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-linux-arm64.AppImage
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-arm64.AppImage
- name: Upload Linux x64 flatpak
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-linux-x86_64.flatpak
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-x86_64.flatpak
- name: Upload Windows x32 exe
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-windows-ia32.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-ia32.exe
- name: Upload Windows x64 exe
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-windows-x64.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-x64.exe
- name: Upload Windows arm64 exe
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-windows-arm64.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-arm64.exe
- name: Upload Mac x64 dmg
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-mac-x64.dmg
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-mac-x64.dmg
- name: Upload Mac arm64 dmg
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-mac-arm64.dmg
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-mac-arm64.dmg
name: actual-electron-${{ matrix.os }}
path: |
packages/desktop-electron/dist/*.dmg
packages/desktop-electron/dist/*.exe
!packages/desktop-electron/dist/Actual-windows.exe
packages/desktop-electron/dist/*.AppImage
packages/desktop-electron/dist/*.flatpak
- name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@v4
with:
name: actual-electron-${{ matrix.os }}-appx
path: |

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.ref }}
- name: Bump package versions
@@ -48,7 +48,7 @@ jobs:
echo "version=$NEW_WEB_VERSION" >> "$GITHUB_OUTPUT"
- name: Create PR
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
uses: peter-evans/create-pull-request@v7
with:
commit-message: '🔖 (${{ steps.bump_package_versions.outputs.version }})'
title: '🔖 (${{ steps.bump_package_versions.outputs.version }})'

View File

@@ -12,7 +12,7 @@ jobs:
if: github.repository == 'actualbudget/actual'
steps:
- name: Check out main repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@v4
with:
path: actual
- name: Set up environment
@@ -44,7 +44,7 @@ jobs:
push \
actualbudget/actual
- name: Check out updated translations
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@v4
with:
ssh-key: ${{ secrets.STRING_IMPORT_DEPLOY_KEY }}
repository: actualbudget/translations

View File

@@ -9,17 +9,16 @@ jobs:
if: ${{ github.event.label.name == 'feature' }}
runs-on: ubuntu-latest
steps:
- uses: actions-ecosystem/action-add-labels@bd52874380e3909a1ac983768df6976535ece7f8 # v1.1.0
- uses: actions-ecosystem/action-add-labels@v1
with:
labels: needs votes
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Add reactions
uses: aidan-mundy/react-to-issue@109392cac5159c2df6c47c8ab3b5d6b708852fe5 # v1.1.2
uses: aidan-mundy/react-to-issue@v1.1.1
with:
issue-number: ${{ github.event.issue.number }}
reactions: '+1'
- name: Create comment
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
uses: peter-evans/create-or-update-comment@v3
with:
issue-number: ${{ github.event.issue.number }}
body: |

View File

@@ -24,10 +24,10 @@ jobs:
runs-on: ubuntu-latest
steps:
# This is not a security concern because we have approved & merged the PR
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 20
- name: Handle feature requests
run: node .github/actions/handle-feature-requests.js
env:

View File

@@ -9,6 +9,6 @@ jobs:
if: ${{ !contains(github.event.issue.labels.*.name, 'feature') && contains(github.event.issue.labels.*.name, 'help wanted') }}
runs-on: ubuntu-latest
steps:
- uses: actions-ecosystem/action-remove-labels@2ce5d41b4b6aa8503e285553f75ed56e0a40bae0 # v1.3.0
- uses: actions-ecosystem/action-remove-labels@v1
with:
labels: help wanted

View File

@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Repository Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup

View File

@@ -1,131 +0,0 @@
name: Publish nightly desktop app
# Publish nightly version of desktop app - Runs every day at midnight
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
defaults:
run:
shell: bash
env:
CI: true
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
strategy:
matrix:
os:
- ubuntu-22.04
- windows-latest
- macos-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools
- if: ${{ ! startsWith(matrix.os, 'windows') }}
run: |
mkdir .venv
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install setuptools
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
run: |
sudo apt-get update
sudo apt-get install flatpak -y
sudo apt-get install flatpak-builder -y
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
sudo flatpak install org.freedesktop.Sdk//24.08 -y
sudo flatpak install org.freedesktop.Platform//24.08 -y
sudo flatpak install org.electronjs.Electron2.BaseApp//24.08 -y
- name: Set up environment
uses: ./.github/actions/setup
- name: Update package versions
run: |
# Get new nightly version
NEW_DESKTOP_APP_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-electron/package.json --type nightly)
# Set package version
npm version $NEW_DESKTOP_APP_VERSION --no-git-tag-version --workspace=desktop-electron --no-workspaces-update
- name: Build Electron for Mac
if: ${{ startsWith(matrix.os, 'macos') }}
run: ./bin/package-electron
env:
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
CSC_LINK: ${{ secrets.CSC_LINK }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Build Electron
if: ${{ ! startsWith(matrix.os, 'macos') }}
run: ./bin/package-electron
- name: Upload Linux x64 AppImage
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-linux-x86_64.AppImage
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-x86_64.AppImage
- name: Upload Linux arm64 AppImage
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-linux-arm64.AppImage
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-arm64.AppImage
- name: Upload Windows x32 exe
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-windows-ia32.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-ia32.exe
- name: Upload Windows x64 exe
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-windows-x64.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-x64.exe
- name: Upload Windows arm64 exe
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-windows-arm64.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-arm64.exe
- name: Upload Mac x64 dmg
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-mac-x64.dmg
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-mac-x64.dmg
- name: Upload Mac arm64 dmg
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-mac-arm64.dmg
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-mac-arm64.dmg
- name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: actual-electron-${{ matrix.os }}-appx
path: |
packages/desktop-electron/dist/*.appx

View File

@@ -12,7 +12,7 @@ jobs:
name: Build and pack npm packages
if: github.event.repository.fork == false
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
@@ -49,7 +49,7 @@ jobs:
yarn workspace @actual-app/api pack --filename @actual-app/api.tgz
- name: Upload package artifacts
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@v4
with:
name: npm-packages
path: |
@@ -66,14 +66,14 @@ jobs:
packages: write
steps:
- name: Download the artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@v4
with:
name: npm-packages
- name: Setup node and npm registry
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 20
registry-url: 'https://registry.npmjs.org'
- name: Publish Web

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
name: Build and pack npm packages
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
@@ -32,7 +32,7 @@ jobs:
yarn workspace @actual-app/api pack --filename @actual-app/api.tgz
- name: Upload package artifacts
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@v4
with:
name: npm-packages
path: |
@@ -49,14 +49,14 @@ jobs:
packages: write
steps:
- name: Download the artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@v4
with:
name: npm-packages
- name: Setup node and npm registry
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 20
registry-url: 'https://registry.npmjs.org'
- name: Publish Web

View File

@@ -12,21 +12,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
- name: Get changed files
id: changed-files
run: |
git fetch origin ${{ github.base_ref }}
CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
NON_DOCS_FILES=$(echo "$CHANGED_FILES" | grep -v -e "^packages/docs/" -e "^\.github/actions/docs-spelling/" || true)
if [ -z "$NON_DOCS_FILES" ] && [ -n "$CHANGED_FILES" ]; then
echo "only_docs=true" >> $GITHUB_OUTPUT
echo "only documentation files changed, skipping release notes check"
else
echo "only_docs=false" >> $GITHUB_OUTPUT
fi
uses: actions/checkout@v4
- name: Check release notes
if: startsWith(github.head_ref, 'release/') == false
uses: actualbudget/actions/release-notes/check@main
- name: Generate release notes
if: startsWith(github.head_ref, 'release/') == true
uses: actualbudget/actions/release-notes/generate@main

View File

@@ -26,120 +26,64 @@ jobs:
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- name: Checkout base branch
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.base_ref }}
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Wait for ${{github.base_ref}} web build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: master-web-build
- name: Wait for ${{github.base_ref}} build to succeed
uses: fountainhead/action-wait-for-check@v1.2.0
id: master-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: web
ref: ${{github.base_ref}}
- name: Wait for ${{github.base_ref}} API build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: master-api-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: api
ref: ${{github.base_ref}}
- name: Wait for PR build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: wait-for-web-build
uses: fountainhead/action-wait-for-check@v1.2.0
id: wait-for-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: web
ref: ${{github.event.pull_request.head.sha}}
- name: Wait for API PR build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: wait-for-api-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: api
ref: ${{github.event.pull_request.head.sha}}
- name: Report build failure
if: steps.wait-for-web-build.outputs.conclusion == 'failure' || steps.wait-for-api-build.outputs.conclusion == 'failure'
if: steps.wait-for-build.outputs.conclusion == 'failure'
run: |
echo "Build failed on PR branch or ${{github.base_ref}}"
exit 1
- name: Download build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@v6
id: pr-build
with:
branch: ${{github.base_ref}}
workflow: build.yml
name: build-stats
path: base
- name: Download web build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
id: pr-web-build
with:
branch: ${{github.base_ref}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: build-stats
path: base
- name: Download API build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
id: pr-api-build
with:
branch: ${{github.base_ref}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: api-build-stats
path: base
- name: Download build stats from PR
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
- name: Download build artifact from PR
uses: dawidd6/action-download-artifact@v6
with:
pr: ${{github.event.pull_request.number}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: build-stats
path: head
allow_forks: true
- name: Download API stats from PR
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with:
pr: ${{github.event.pull_request.number}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: api-build-stats
path: head
allow_forks: true
- name: Strip content hashes from stats files
run: |
if [ -f ./head/web-stats.json ]; then
sed -i -E 's/index\.[0-9a-zA-Z_-]{8,}\./index./g' ./head/web-stats.json
sed -i -E 's/\.[0-9a-zA-Z_-]{8,}\.chunk\././g' ./head/web-stats.json
fi
if [ -f ./base/web-stats.json ]; then
sed -i -E 's/index\.[0-9a-zA-Z_-]{8,}\./index./g' ./base/web-stats.json
sed -i -E 's/\.[0-9a-zA-Z_-]{8,}\.chunk\././g' ./base/web-stats.json
fi
for file in ./head/*.json ./base/*.json; do
if [ -f "$file" ]; then
sed -i -E 's/\.[0-9a-f]{8,}\././g' "$file"
fi
done
- name: Generate combined bundle stats comment
run: |
node packages/ci-actions/bin/bundle-stats-comment.mjs \
--base desktop-client=./base/web-stats.json \
--base loot-core=./base/loot-core-stats.json \
--base api=./base/api-stats.json \
--head desktop-client=./head/web-stats.json \
--head loot-core=./head/loot-core-stats.json \
--head api=./head/api-stats.json \
--identifier combined > bundle-stats-comment.md
- name: Post combined bundle stats comment
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
node packages/ci-actions/bin/update-bundle-stats-comment.mjs \
--comment-file bundle-stats-comment.md \
--identifier '<!--- bundlestats-action-comment key:combined --->'
sed -i -E 's/index\.[0-9a-zA-Z_-]{8,}\./index./g' ./head/web-stats.json
sed -i -E 's/\.[0-9a-zA-Z_-]{8,}\.chunk\././g' ./head/web-stats.json
sed -i -E 's/\.[0-9a-f]{8,}\././g' ./head/*.json
sed -i -E 's/index\.[0-9a-zA-Z_-]{8,}\./index./g' ./base/web-stats.json
sed -i -E 's/\.[0-9a-zA-Z_-]{8,}\.chunk\././g' ./base/web-stats.json
sed -i -E 's/\.[0-9a-f]{8,}\././g' ./base/*.json
- uses: twk3/rollup-size-compare-action@v1.1.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
current-stats-json-path: ./head/web-stats.json
base-stats-json-path: ./base/web-stats.json
title: desktop-client
- uses: twk3/rollup-size-compare-action@v1.1.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
current-stats-json-path: ./head/loot-core-stats.json
base-stats-json-path: ./base/loot-core-stats.json
title: loot-core

View File

@@ -8,7 +8,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
- uses: actions/stale@v9
with:
stale-pr-message: 'This PR is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.'
close-pr-message: 'This PR was closed because it has been stalled for 5 days with no activity.'
@@ -18,7 +18,7 @@ jobs:
stale-wip:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
- uses: actions/stale@v9
with:
stale-pr-message: ':wave: Hi! It looks like this PR has not had any changes for a week now. Would you like someone to review this PR? If so - please remove the "[WIP]" prefix from the PR title. That will let the community know that this PR is open for a review.'
days-before-stale: 7
@@ -29,7 +29,7 @@ jobs:
stale-needs-info:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
- uses: actions/stale@v9
with:
stale-issue-label: 'needs info'
days-before-stale: -1

119
.github/workflows/update-vrt.yml vendored Normal file
View File

@@ -0,0 +1,119 @@
name: /update-vrt
on:
issue_comment:
types: [created]
permissions:
pull-requests: read
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number }}-${{ contains(github.event.comment.body, '/update-vrt') }}
cancel-in-progress: true
jobs:
update-vrt:
name: Update VRT
runs-on: ubuntu-latest
if: |
github.event.issue.pull_request &&
contains(github.event.comment.body, '/update-vrt')
container:
image: mcr.microsoft.com/playwright:v1.52.0-jammy
steps:
- name: Get PR branch
# Until https://github.com/xt0rted/pull-request-comment-branch/issues/322 is resolved we use the forked version
uses: gotson/pull-request-comment-branch@head-repo-owner-dist
id: comment-branch
- uses: actions/checkout@v4
with:
repository: ${{ steps.comment-branch.outputs.head_owner }}/${{ steps.comment-branch.outputs.head_repo }}
ref: ${{ steps.comment-branch.outputs.head_ref }}
- name: Set up environment
uses: ./.github/actions/setup
- name: Run VRT Tests on Desktop app
continue-on-error: true
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop --update-snapshots
- name: Wait for Netlify build to finish
id: netlify
env:
COMMIT_SHA: ${{ steps.comment-branch.outputs.head_sha }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./.github/actions/netlify-wait-for-build
- name: Run VRT Tests on Netlify URL
run: yarn vrt --update-snapshots
env:
E2E_START_URL: ${{ steps.netlify.outputs.url }}
- name: Create patch
run: |
git config --system --add safe.directory "*"
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git reset
git add "**/*.png"
if git diff --staged --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "Update VRT"
git format-patch -1 HEAD --stdout > Update-VRT.patch
- uses: actions/upload-artifact@v4
with:
name: patch
path: Update-VRT.patch
push-patch:
runs-on: ubuntu-latest
needs: update-vrt
permissions:
contents: write
pull-requests: write
steps:
- name: Get PR branch
# Until https://github.com/xt0rted/pull-request-comment-branch/issues/322 is resolved we use the forked version
uses: gotson/pull-request-comment-branch@head-repo-owner-dist
id: comment-branch
- uses: actions/checkout@v4
with:
repository: ${{ steps.comment-branch.outputs.head_owner }}/${{ steps.comment-branch.outputs.head_repo }}
ref: ${{ steps.comment-branch.outputs.head_ref }}
- uses: actions/download-artifact@v4
continue-on-error: true
with:
name: patch
- name: Apply patch and push
env:
BRANCH_NAME: ${{ steps.comment-branch.outputs.head_ref }}
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git apply Update-VRT.patch
git add "**/*.png"
if git diff --staged --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "Update VRT"
git push origin HEAD:${BRANCH_NAME}
- name: Add finished reaction
uses: dkershner6/reaction-action@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
commentId: ${{ github.event.comment.id }}
reaction: 'rocket'
add-starting-reaction:
runs-on: ubuntu-latest
if: |
github.event.issue.pull_request &&
contains(github.event.comment.body, '/update-vrt')
permissions:
pull-requests: write
steps:
- name: React to comment
uses: dkershner6/reaction-action@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
commentId: ${{ github.event.comment.id }}
reaction: '+1'

View File

@@ -1,151 +0,0 @@
name: VRT Update - Apply
# SECURITY: This workflow runs in trusted base repo context.
# It treats the patch artifact as untrusted data, validates it contains only PNGs,
# and safely applies it to the contributor's fork branch.
on:
workflow_run:
workflows: ['VRT Update - Generate']
types:
- completed
permissions:
contents: write
pull-requests: write
jobs:
apply-vrt-updates:
name: Apply VRT Updates
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Download patch artifact
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: vrt-patch-*
path: /tmp/artifacts
- name: Download metadata artifact
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: vrt-metadata-*
path: /tmp/metadata
- name: Extract metadata
id: metadata
run: |
if [ ! -f "/tmp/metadata/pr-number.txt" ]; then
echo "No metadata found, skipping..."
exit 0
fi
PR_NUMBER=$(cat "/tmp/metadata/pr-number.txt")
HEAD_REF=$(cat "/tmp/metadata/head-ref.txt")
HEAD_REPO=$(cat "/tmp/metadata/head-repo.txt")
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
echo "head_ref=$HEAD_REF" >> "$GITHUB_OUTPUT"
echo "head_repo=$HEAD_REPO" >> "$GITHUB_OUTPUT"
echo "Found PR #$PR_NUMBER: $HEAD_REPO @ $HEAD_REF"
- name: Checkout fork branch
if: steps.metadata.outputs.pr_number != ''
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
repository: ${{ steps.metadata.outputs.head_repo }}
ref: ${{ steps.metadata.outputs.head_ref }}
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Validate and apply patch
if: steps.metadata.outputs.pr_number != ''
id: apply
run: |
PATCH_FILE="/tmp/artifacts/vrt-update.patch"
if [ ! -f "$PATCH_FILE" ]; then
echo "No patch file found"
exit 0
fi
echo "Found patch file: $PATCH_FILE"
# Validate patch only contains PNG files
echo "Validating patch contains only PNG files..."
if grep -E '^(\+\+\+|---) [ab]/' "$PATCH_FILE" | grep -v '\.png$'; then
echo "ERROR: Patch contains non-PNG files! Rejecting for security."
echo "applied=false" >> "$GITHUB_OUTPUT"
echo "error=Patch validation failed: contains non-PNG files" >> "$GITHUB_OUTPUT"
exit 1
fi
# Extract file list for verification
FILES_CHANGED=$(grep -E '^\+\+\+ b/' "$PATCH_FILE" | sed 's/^+++ b\///' | wc -l)
echo "Patch modifies $FILES_CHANGED PNG file(s)"
# Configure git
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
# Apply patch
echo "Applying patch..."
if git apply --check "$PATCH_FILE" 2>&1; then
git apply "$PATCH_FILE"
# Stage only PNG files (extra safety)
git add "**/*.png"
if git diff --staged --quiet; then
echo "No changes after applying patch"
echo "applied=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Commit
git commit -m "Update VRT screenshots" -m "Auto-generated by VRT workflow" -m "PR: #${{ steps.metadata.outputs.pr_number }}"
echo "applied=true" >> "$GITHUB_OUTPUT"
else
echo "Patch could not be applied cleanly"
echo "applied=false" >> "$GITHUB_OUTPUT"
echo "error=Patch conflicts with current branch state" >> "$GITHUB_OUTPUT"
exit 1
fi
- name: Push changes
if: steps.apply.outputs.applied == 'true'
env:
HEAD_REF: ${{ steps.metadata.outputs.head_ref }}
HEAD_REPO: ${{ steps.metadata.outputs.head_repo }}
run: |
git push origin "HEAD:refs/heads/$HEAD_REF"
echo "Successfully pushed VRT updates to $HEAD_REPO@$HEAD_REF"
- name: Comment on PR - Success
if: steps.apply.outputs.applied == 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
await github.rest.issues.createComment({
issue_number: ${{ steps.metadata.outputs.pr_number }},
owner: context.repo.owner,
repo: context.repo.repo,
body: '✅ VRT screenshots have been automatically updated.'
});
- name: Comment on PR - Failure
if: failure() && steps.metadata.outputs.pr_number != ''
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const error = `${{ steps.apply.outputs.error }}` || 'Unknown error occurred';
await github.rest.issues.createComment({
issue_number: ${{ steps.metadata.outputs.pr_number }},
owner: context.repo.owner,
repo: context.repo.repo,
body: `❌ Failed to apply VRT updates: ${error}\n\nPlease check the workflow logs for details.`
});

View File

@@ -1,105 +0,0 @@
name: VRT Update - Generate
# SECURITY: This workflow runs in untrusted fork context with no write permissions.
# It only generates VRT patch artifacts that are later applied by vrt-update-apply.yml
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'packages/**'
- '.github/workflows/vrt-update-generate.yml'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
generate-vrt-updates:
name: Generate VRT Updates
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.56.0-jammy
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up environment
uses: ./.github/actions/setup
- name: Run VRT Tests on Desktop app
continue-on-error: true
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop --update-snapshots
- name: Wait for Netlify build to finish
id: netlify
env:
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./.github/actions/netlify-wait-for-build
- name: Run VRT Tests on Netlify URL
continue-on-error: true
run: yarn vrt --update-snapshots
env:
E2E_START_URL: ${{ steps.netlify.outputs.url }}
- name: Create patch with PNG changes only
id: create-patch
run: |
# Trust the repository directory (required for container environments)
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
# Stage only PNG files
git add "**/*.png"
# Check if there are any changes
if git diff --staged --quiet; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No VRT changes to commit"
exit 0
fi
echo "has_changes=true" >> "$GITHUB_OUTPUT"
# Create commit and patch
git commit -m "Update VRT screenshots"
git format-patch -1 HEAD --stdout > vrt-update.patch
# Validate patch only contains PNG files
if grep -E '^(\+\+\+|---) [ab]/' vrt-update.patch | grep -v '\.png$'; then
echo "ERROR: Patch contains non-PNG files!"
exit 1
fi
echo "Patch created successfully with PNG changes only"
- name: Upload patch artifact
if: steps.create-patch.outputs.has_changes == 'true'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: vrt-patch-${{ github.event.pull_request.number }}
path: vrt-update.patch
retention-days: 5
- name: Save PR metadata
if: steps.create-patch.outputs.has_changes == 'true'
run: |
mkdir -p pr-metadata
echo "${{ github.event.pull_request.number }}" > pr-metadata/pr-number.txt
echo "${{ github.event.pull_request.head.ref }}" > pr-metadata/head-ref.txt
echo "${{ github.event.pull_request.head.repo.full_name }}" > pr-metadata/head-repo.txt
- name: Upload PR metadata
if: steps.create-patch.outputs.has_changes == 'true'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: vrt-metadata-${{ github.event.pull_request.number }}
path: pr-metadata/
retention-days: 5

8
.gitignore vendored
View File

@@ -7,6 +7,9 @@ Actual-*
**/xcuserdata/*
export-2020-01-10.csv
# Secrets
.secret-tokens
# MacOS
.DS_Store
@@ -23,8 +26,6 @@ packages/desktop-electron/build
packages/desktop-electron/.electron-symbols
packages/desktop-electron/dist
packages/desktop-electron/loot-core
packages/desktop-client/service-worker
packages/plugins-service/dist
bundle.desktop.js
bundle.desktop.js.map
bundle.mobile.js
@@ -62,6 +63,3 @@ build/
# .d.ts files aren't type-checked with skipLibCheck set to true
*.d.ts
# Lage cache
.lage/

2
.nvmrc
View File

@@ -1 +1 @@
v22/*
v20/*

View File

@@ -10,7 +10,6 @@ packages/desktop-client/bundle.browser.js
packages/desktop-client/stats.json
packages/desktop-client/.swc/
packages/desktop-client/build/
packages/desktop-client/dev-dist/
packages/desktop-client/locale/
packages/desktop-client/build-electron/
packages/desktop-client/build-stats/
@@ -27,10 +26,5 @@ packages/loot-core/**/node_modules/*
packages/loot-core/**/lib-dist/*
packages/loot-core/**/proto/*
packages/sync-server/coverage/
packages/sync-server/user-files/
packages/sync-server/server-files/
.yarn/*
upcoming-release-notes/*
# temporary
packages/docs/*

2
.secret-tokens.example Normal file
View File

@@ -0,0 +1,2 @@
export APPLE_ID=example@email.com
export APPLE_APP_SPECIFIC_PASSWORD=password

File diff suppressed because one or more lines are too long

948
.yarn/releases/yarn-4.9.1.cjs vendored Executable file

File diff suppressed because one or more lines are too long

View File

@@ -6,4 +6,4 @@ enableTransparentWorkspaces: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.10.3.cjs
yarnPath: .yarn/releases/yarn-4.9.1.cjs

585
AGENTS.md
View File

@@ -1,585 +0,0 @@
# AGENTS.md - Guide for AI Agents Working with Actual Budget
This guide provides comprehensive information for AI agents (like Cursor) working with the Actual Budget codebase.
## Project Overview
**Actual Budget** is a local-first personal finance tool written in TypeScript/JavaScript. It's 100% free and open-source with synchronization capabilities across devices.
- **Repository**: https://github.com/actualbudget/actual
- **Community Docs**: https://github.com/actualbudget/actual/tree/master/packages/docs or https://actualbudget.org/docs
- **License**: MIT
- **Primary Language**: TypeScript (with React)
- **Build System**: Yarn 4 workspaces (monorepo)
## Quick Start Commands
### Essential Commands (Run from Root)
```bash
# Type checking (ALWAYS run before committing)
yarn typecheck
# Linting and formatting (with auto-fix)
yarn lint:fix
# Run all tests
yarn test
# Start development server (browser)
yarn start
# Start with sync server
yarn start:server-dev
# Start desktop app development
yarn start:desktop
```
### Important Rules
- **ALWAYS run yarn commands from the root directory** - never run them in child workspaces
- Use `yarn workspace <workspace-name> run <command>` for workspace-specific tasks
- Tests run once and exit by default (using `vitest --run`)
### Task Orchestration with Lage
The project uses **[lage](https://microsoft.github.io/lage/)** (a task runner for JavaScript monorepos) to efficiently run tests and other tasks across multiple workspaces:
- **Parallel execution**: Runs tests in parallel across workspaces for faster feedback
- **Smart caching**: Caches test results to skip unchanged packages (cached in `.lage/` directory)
- **Dependency awareness**: Understands workspace dependencies and execution order
- **Continues on error**: Uses `--continue` flag to run all packages even if one fails
**Lage Commands:**
```bash
# Run all tests across all packages
yarn test # Equivalent to: lage test --continue
# Run tests without cache (for debugging/CI)
yarn test:debug # Equivalent to: lage test --no-cache --continue
```
Configuration is in `lage.config.js` at the project root.
## Architecture & Package Structure
### Core Packages
#### 1. **loot-core** (`packages/loot-core/`)
The core application logic that runs on any platform.
- Business logic, database operations, and calculations
- Platform-agnostic code
- Exports for both browser and node environments
- Test commands:
```bash
# Run all loot-core tests
yarn workspace loot-core run test
# Or run tests across all packages using lage
yarn test
```
#### 2. **desktop-client** (`packages/desktop-client/` - aliased as `@actual-app/web`)
The React-based UI for web and desktop.
- React components using functional programming patterns
- E2E tests using Playwright
- Vite for bundling
- Commands:
```bash
# Development
yarn workspace @actual-app/web start:browser
# Build
yarn workspace @actual-app/web build
# E2E tests
yarn workspace @actual-app/web e2e
# Visual regression tests
yarn workspace @actual-app/web vrt
```
#### 3. **desktop-electron** (`packages/desktop-electron/`)
Electron wrapper for the desktop application.
- Window management and native OS integration
- E2E tests for Electron-specific features
#### 4. **api** (`packages/api/` - aliased as `@actual-app/api`)
Public API for programmatic access to Actual.
- Node.js API
- Designed for integrations and automation
- Commands:
```bash
# Build
yarn workspace @actual-app/api build
# Run tests
yarn workspace @actual-app/api test
# Or use lage to run all tests
yarn test
```
#### 5. **sync-server** (`packages/sync-server/` - aliased as `@actual-app/sync-server`)
Synchronization server for multi-device support.
- Express-based server
- Currently transitioning to TypeScript (mostly JavaScript)
- Commands:
```bash
yarn workspace @actual-app/sync-server start
```
#### 6. **component-library** (`packages/component-library/` - aliased as `@actual-app/components`)
Reusable React UI components.
- Shared components like Button, Input, Menu, etc.
- Theme system and design tokens
- Icons (375+ icons in SVG/TSX format)
#### 7. **crdt** (`packages/crdt/` - aliased as `@actual-app/crdt`)
CRDT (Conflict-free Replicated Data Type) implementation for data synchronization.
- Protocol buffers for serialization
- Core sync logic
#### 8. **plugins-service** (`packages/plugins-service/`)
Service for handling plugins/extensions.
#### 9. **eslint-plugin-actual** (`packages/eslint-plugin-actual/`)
Custom ESLint rules specific to Actual.
- `no-untranslated-strings`: Enforces i18n usage
- `prefer-trans-over-t`: Prefers Trans component over t() function
- `prefer-logger-over-console`: Enforces using logger instead of console
- `typography`: Typography rules
- `prefer-if-statement`: Prefers explicit if statements
## Development Workflow
### 1. Making Changes
When implementing changes:
1. Read relevant files to understand current implementation
2. Make focused, incremental changes
3. Run type checking: `yarn typecheck`
4. Run linting: `yarn lint:fix`
5. Run relevant tests
6. Fix any linter errors that are introduced
### 2. Testing Strategy
**Unit Tests (Vitest)**
The project uses **lage** for running tests across all workspaces efficiently.
```bash
# Run all tests across all packages (using lage)
yarn test
# Run tests without cache (for debugging)
yarn test:debug
# Run tests for a specific package
yarn workspace loot-core run test
# Run a specific test file (watch mode)
yarn workspace loot-core run test path/to/test.test.ts
```
**E2E Tests (Playwright)**
```bash
# Run E2E tests for web
yarn e2e
# Desktop Electron E2E (includes full build)
yarn e2e:desktop
# Visual regression tests
yarn vrt
# Visual regression in Docker (consistent environment)
yarn vrt:docker
# Run E2E tests for a specific package
yarn workspace @actual-app/web e2e
```
**Testing Best Practices:**
- Minimize mocked dependencies - prefer real implementations
- Use descriptive test names
- Vitest globals are available: `describe`, `it`, `expect`, `beforeEach`, etc.
- For sync-server tests, globals are explicitly defined in config
### 3. Type Checking
TypeScript configuration uses:
- Incremental compilation
- Strict type checking with `typescript-strict-plugin`
- Platform-specific exports in `loot-core` (node vs browser)
Always run `yarn typecheck` before committing.
### 4. Internationalization (i18n)
- Use `Trans` component instead of `t()` function when possible
- All user-facing strings must be translated
- Generate i18n files: `yarn generate:i18n`
- Custom ESLint rules enforce translation usage
## Code Style & Conventions
### TypeScript Guidelines
**Type Usage:**
- Use TypeScript for all code
- Prefer `type` over `interface`
- Avoid `enum` - use objects or maps
- Avoid `any` or `unknown` unless absolutely necessary
- Look for existing type definitions in the codebase
- Avoid type assertions (`as`, `!`) - prefer `satisfies`
- Use inline type imports: `import { type MyType } from '...'`
**Naming:**
- Use descriptive variable names with auxiliary verbs (e.g., `isLoaded`, `hasError`)
- Named exports for components and utilities (avoid default exports except in specific cases)
**Code Structure:**
- Functional and declarative programming patterns - avoid classes
- Use the `function` keyword for pure functions
- Prefer iteration and modularization over code duplication
- Structure files: exported component/page, helpers, static content, types
- Create new components in their own files
**React Patterns:**
- Don't use `React.FunctionComponent` or `React.FC` - type props directly
- Don't use `React.*` patterns - use named imports instead
- Use `<Link>` instead of `<a>` tags
- Use custom hooks from `src/hooks` (not react-router directly):
- `useNavigate()` from `src/hooks` (not react-router)
- `useDispatch()`, `useSelector()`, `useStore()` from `src/redux` (not react-redux)
- Avoid unstable nested components
- Use `satisfies` for type narrowing
**JSX Style:**
- Declarative JSX, minimal and readable
- Avoid unnecessary curly braces in conditionals
- Use concise syntax for simple statements
- Prefer explicit expressions (`condition && <Component />`)
### Import Organization
Imports are automatically organized by ESLint with the following order:
1. React imports (first)
2. Built-in Node.js modules
3. External packages
4. Actual packages (`loot-core`, `@actual-app/components` - legacy pattern `loot-design` may appear in old code)
5. Parent imports
6. Sibling imports
7. Index imports
Always maintain newlines between import groups.
### Platform-Specific Code
- Don't directly reference platform-specific imports (`.api`, `.web`, `.electron`)
- Use conditional exports in `loot-core` for platform-specific code
- Platform resolution happens at build time via package.json exports
### Restricted Patterns
**Never:**
- Use `console.*` (use logger instead - enforced by ESLint)
- Import from `uuid` without destructuring: use `import { v4 as uuidv4 } from 'uuid'`
- Import colors directly - use theme instead
- Import `@actual-app/web/*` in `loot-core`
**Git Commands:**
- Never update git config
- Never run destructive git operations (force push, hard reset) unless explicitly requested
- Never skip hooks (--no-verify, --no-gpg-sign)
- Never force push to main/master
- Never commit unless explicitly asked
## File Structure Patterns
### Typical Component File
```typescript
import { type ComponentType } from 'react';
// ... other imports
type MyComponentProps = {
// Props definition
};
export function MyComponent({ prop1, prop2 }: MyComponentProps) {
// Component logic
return (
// JSX
);
}
```
### Test File
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
// ... imports
describe('ComponentName', () => {
it('should behave as expected', () => {
// Test logic
expect(result).toBe(expected);
});
});
```
## Important Directories & Files
### Configuration Files
- `/package.json` - Root workspace configuration, scripts
- `/lage.config.js` - Lage task runner configuration
- `/eslint.config.mjs` - ESLint configuration (flat config format)
- `/tsconfig.json` - Root TypeScript configuration
- `/.cursorignore`, `/.gitignore` - Ignored files
- `/yarn.lock` - Dependency lockfile (Yarn 4)
### Documentation
- `/README.md` - Project overview
- `/CONTRIBUTING.md` - Points to community docs
- `/upcoming-release-notes/` - Release notes for next version
- `/CODEOWNERS` - Code ownership definitions
### Build Artifacts (Don't Edit)
- `packages/*/lib-dist/` - Built output
- `packages/*/dist/` - Built output
- `packages/*/build/` - Built output
- `packages/desktop-client/playwright-report/` - Test reports
- `packages/desktop-client/test-results/` - Test results
- `.lage/` - Lage task runner cache (improves test performance)
### Key Source Directories
- `packages/loot-core/src/client/` - Client-side core logic
- `packages/loot-core/src/server/` - Server-side core logic
- `packages/loot-core/src/shared/` - Shared utilities
- `packages/loot-core/src/types/` - Type definitions
- `packages/desktop-client/src/components/` - React components
- `packages/desktop-client/src/hooks/` - Custom React hooks
- `packages/desktop-client/e2e/` - End-to-end tests
- `packages/component-library/src/` - Reusable components
- `packages/component-library/src/icons/` - Icon components (auto-generated, don't edit)
## Common Development Tasks
### Running Specific Tests
```bash
# Run all tests across all packages (recommended)
yarn test
# Unit test for a specific file in loot-core (watch mode)
yarn workspace loot-core run test src/path/to/file.test.ts
# E2E test for a specific file
yarn workspace @actual-app/web run playwright test accounts.test.ts --browser=chromium
```
### Building for Production
```bash
# Browser build
yarn build:browser
# Desktop build
yarn build:desktop
# API build
yarn build:api
# Sync server build
yarn build:server
```
### Type Checking Specific Packages
TypeScript uses project references. Run `yarn typecheck` from root to check all packages.
### Debugging Tests
```bash
# Run tests in debug mode (without parallelization)
yarn test:debug
# Run specific E2E test with headed browser
yarn workspace @actual-app/web run playwright test --headed --debug accounts.test.ts
```
### Working with Icons
Icons in `packages/component-library/src/icons/` are auto-generated. Don't manually edit them.
## Troubleshooting
### Type Errors
1. Run `yarn typecheck` to see all type errors
2. Check if types are imported correctly
3. Look for existing type definitions in `packages/loot-core/src/types/`
4. Use `satisfies` instead of `as` for type narrowing
### Linter Errors
1. Run `yarn lint:fix` to auto-fix many issues
2. Check ESLint output for specific rule violations
3. Custom rules:
- `actual/no-untranslated-strings` - Add i18n
- `actual/prefer-trans-over-t` - Use Trans component
- `actual/prefer-logger-over-console` - Use logger
- Check `eslint.config.mjs` for complete rules
### Test Failures
1. Check if test is running in correct environment (node vs web)
2. For Vitest: check `vitest.config.ts` or `vitest.web.config.ts`
3. For Playwright: check `playwright.config.ts`
4. Ensure mock minimization - prefer real implementations
5. **Lage cache issues**: Clear cache with `rm -rf .lage` if tests behave unexpectedly
6. **Tests continue on error**: With `--continue` flag, all packages run even if one fails
### Import Resolution Issues
1. Check `tsconfig.json` for path mappings
2. Check package.json `exports` field (especially for loot-core)
3. Verify platform-specific imports (`.web`, `.electron`, `.api`)
4. Use absolute imports in `desktop-client` (enforced by ESLint)
### Build Failures
1. Clean build artifacts: `rm -rf packages/*/dist packages/*/lib-dist packages/*/build`
2. Reinstall dependencies: `yarn install`
3. Check Node.js version (requires >=20)
4. Check Yarn version (requires ^4.9.1)
## Testing Patterns
### Unit Tests
- Located alongside source files or in `__tests__` directories
- Use `.test.ts`, `.test.tsx`, `.spec.js` extensions
- Vitest is the test runner
- Minimize mocking - prefer real implementations
### E2E Tests
- Located in `packages/desktop-client/e2e/`
- Use Playwright test runner
- Visual regression snapshots in `*-snapshots/` directories
- Page models in `e2e/page-models/` for reusable page interactions
- Mobile tests have `.mobile.test.ts` suffix
### Visual Regression Tests (VRT)
- Snapshots stored per test file in `*-snapshots/` directories
- Use Docker for consistent environment: `yarn vrt:docker`
## Additional Resources
- **Community Documentation**: https://actualbudget.org/docs/contributing/
- **Discord Community**: https://discord.gg/pRYNYr4W5A
- **GitHub Issues**: https://github.com/actualbudget/actual/issues
- **Feature Requests**: Label "needs votes" sorted by reactions
## Code Quality Checklist
Before committing changes, ensure:
- [ ] `yarn typecheck` passes
- [ ] `yarn lint:fix` has been run
- [ ] Relevant tests pass
- [ ] No new console.\* usage (use logger)
- [ ] User-facing strings are translated
- [ ] Prefer `type` over `interface`
- [ ] Named exports used (not default exports)
- [ ] Imports are properly ordered
- [ ] Platform-specific code uses proper exports
- [ ] No unnecessary type assertions
## Pull Request Guidelines
When creating pull requests:
- **AI-Generated PRs**: If you create a PR using AI assistance, add the **"AI generated"** label to the pull request. This helps maintainers understand the nature of the contribution.
## Performance Considerations
- **Bundle Size**: Check with rollup-plugin-visualizer
- **Type Checking**: Uses incremental compilation
- **Testing**: Tests run in parallel by default
- **Linting**: ESLint caches results for faster subsequent runs
## Workspace Commands Reference
```bash
# List all workspaces
yarn workspaces list
# Run command in specific workspace
yarn workspace <workspace-name> run <command>
# Run command in all workspaces
yarn workspaces foreach --all run <command>
# Install production dependencies only (for server deployment)
yarn install:server
```
## Environment Requirements
- **Node.js**: >=20
- **Yarn**: ^4.9.1 (managed by packageManager field)
- **Browser Targets**: Electron >= 35.0, modern browsers (see browserslist)
## Migration Notes
The codebase is actively being migrated:
- **JavaScript → TypeScript**: sync-server is in progress
- **Classes → Functions**: Prefer functional patterns
- **React.\* → Named Imports**: Legacy React.\* patterns being removed
When working with older code, follow the newer patterns described in this guide.

View File

@@ -5,7 +5,7 @@
# you are doing.
###################################################
FROM node:22-bookworm as dev
FROM node:20-bullseye as dev
RUN apt-get update -y && apt-get upgrade -y && apt-get install -y openssl
WORKDIR /app
CMD ["sh", "./bin/docker-start"]

View File

@@ -16,7 +16,6 @@ packages/desktop-client/bin/remove-untranslated-languages
export NODE_OPTIONS="--max-old-space-size=4096"
yarn workspace plugins-service build
yarn workspace loot-core build:browser
yarn workspace @actual-app/web build:browser

View File

@@ -41,7 +41,6 @@ packages/desktop-client/bin/remove-untranslated-languages
export NODE_OPTIONS="--max-old-space-size=4096"
yarn workspace plugins-service build
yarn workspace loot-core build:node
yarn workspace @actual-app/web build --mode=desktop # electron specific build
@@ -62,11 +61,14 @@ yarn workspace desktop-electron update-client
echo "Skipping exe build"
else
if [ "$RELEASE" == "production" ]; then
if [ -f ../../.secret-tokens ]; then
source ../../.secret-tokens
fi
yarn build
echo "Created release"
else
yarn build
SKIP_NOTARIZATION=true yarn build
fi
fi
)

View File

@@ -28,5 +28,5 @@ echo "Running VRT tests with the following parameters:"
echo "E2E_START_URL: $E2E_START_URL"
echo "VRT_ARGS: $VRT_ARGS"
MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.56.0-jammy /bin/bash \
MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.52.0-jammy /bin/bash \
-c "E2E_START_URL=$E2E_START_URL yarn vrt $VRT_ARGS"

View File

@@ -1,16 +1,15 @@
import tsParser from '@typescript-eslint/parser';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import pluginImport from 'eslint-plugin-import';
import pluginJSXA11y from 'eslint-plugin-jsx-a11y';
import pluginReact from 'eslint-plugin-react';
import pluginReactHooks from 'eslint-plugin-react-hooks';
import pluginTypescriptPaths from 'eslint-plugin-typescript-paths';
import globals from 'globals';
import pluginTypescript from 'typescript-eslint';
// eslint-disable-next-line import/extensions
import pluginTypescriptPaths from 'eslint-plugin-typescript-paths';
import pluginActual from './packages/eslint-plugin-actual/lib/index.js';
import tsParser from '@typescript-eslint/parser';
const confusingBrowserGlobals = [
// https://github.com/facebook/create-react-app/tree/main/packages/confusing-browser-globals
'addEventListener',
@@ -72,36 +71,37 @@ const confusingBrowserGlobals = [
'top',
];
export default defineConfig(
export default pluginTypescript.config(
{
ignores: [
//temporary
'packages/docs',
'packages/api/app/bundle.api.js',
'packages/api/app/stats.json',
'packages/api/dist',
'packages/api/@types',
'packages/api/migrations',
'packages/crdt/dist',
'packages/component-library/src/icons/**/*',
'packages/desktop-client/bundle.browser.js',
'packages/desktop-client/dev-dist/',
'packages/desktop-client/service-worker/*',
'packages/desktop-client/build/',
'packages/desktop-client/build-electron/',
'packages/desktop-client/build-stats/',
'packages/desktop-client/public/kcab/',
'packages/desktop-client/public/data/',
'packages/desktop-client/**/node_modules/*',
'packages/desktop-client/node_modules/',
'packages/desktop-client/test-results/',
'packages/desktop-client/playwright-report/',
'packages/desktop-electron/client-build/',
'packages/desktop-electron/build/',
'packages/desktop-electron/dist/',
'packages/loot-core/**/node_modules/*',
'packages/loot-core/**/lib-dist/*',
'packages/loot-core/**/proto/*',
'packages/sync-server/user-files/',
'packages/sync-server/server-files/',
'packages/sync-server/build/',
'packages/plugins-core/build/',
'packages/plugins-core/node_modules/',
'.yarn/*',
'.github/*',
'**/build/',
'**/dist/',
'**/node_modules/',
],
},
{
@@ -163,7 +163,7 @@ export default defineConfig(
},
},
{
files: ['**/*.{js,ts,jsx,tsx,mjs,mts}'],
files: ['**/*.{js,ts,jsx,tsx}'],
plugins: {
'jsx-a11y': pluginJSXA11y,
'react-hooks': pluginReactHooks,
@@ -447,7 +447,7 @@ export default defineConfig(
'react-hooks/exhaustive-deps': [
'warn',
{
additionalHooks: '(useQuery|useEffectAfterMount)',
additionalHooks: '(useQuery)',
},
],
@@ -664,7 +664,7 @@ export default defineConfig(
'warn',
{
types: {
// forbid FC as superfluous
// forbid FC as superflous
FunctionComponent: {
message:
'Type the props argument and let TS infer or use ComponentType for a component prop',
@@ -709,18 +709,14 @@ export default defineConfig(
// Allow configuring vitest with default exports (recommended as per vitest docs)
{
files: [
'**/vitest.config.{ts,mts}',
'**/vitest.web.config.ts',
'**/vite.config.{ts,mts}',
'eslint.config.mjs',
],
files: ['**/vitest.config.ts', '**/vitest.web.config.ts'],
rules: {
'import/no-anonymous-default-export': 'off',
'import/no-default-export': 'off',
},
},
{},
{
// TODO: fix the issues in these files
files: [

View File

@@ -1,30 +0,0 @@
/** @type {import('lage').ConfigOptions} */
module.exports = {
pipeline: {
test: {
type: 'npmScript',
options: {
outputGlob: [
'coverage/**',
'**/test-results/**',
'**/playwright-report/**',
],
},
},
build: {
type: 'npmScript',
cache: true,
options: {
outputGlob: ['lib-dist/**', 'dist/**', 'build/**'],
},
},
},
cacheOptions: {
cacheStorageConfig: {
provider: 'local',
outputGlob: ['lib-dist/**', 'dist/**', 'build/**'],
},
},
npmClient: 'yarn',
concurrency: 2,
};

View File

@@ -23,33 +23,28 @@
"start:server-monitor": "yarn workspace @actual-app/sync-server start-monitor",
"start:server-dev": "NODE_ENV=development BROWSER_OPEN=localhost:5006 yarn npm-run-all --parallel 'start:server-monitor' 'start'",
"start:desktop": "yarn desktop-dependencies && npm-run-all --parallel 'start:desktop-*'",
"start:docs": "yarn workspace docs start",
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:browser-backend build:plugins-service",
"desktop-dependencies": "yarn rebuild-electron && yarn workspace loot-core build:browser",
"start:desktop-node": "yarn workspace loot-core watch:node",
"start:desktop-client": "yarn workspace @actual-app/web watch",
"start:desktop-server-client": "yarn workspace @actual-app/web build:browser",
"start:desktop-electron": "yarn workspace desktop-electron watch",
"start:browser": "yarn workspace plugins-service build-dev && npm-run-all --parallel 'start:browser-*'",
"start:service-plugins": "yarn workspace plugins-service watch",
"start:browser": "npm-run-all --parallel 'start:browser-*'",
"start:browser-backend": "yarn workspace loot-core watch:browser",
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
"build:browser-backend": "yarn workspace loot-core build:browser",
"build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build",
"build:browser": "./bin/package-browser",
"build:desktop": "./bin/package-electron",
"build:plugins-service": "yarn workspace plugins-service build",
"build:api": "yarn workspace @actual-app/api build",
"build:docs": "yarn workspace docs build",
"generate:i18n": "yarn workspace @actual-app/web generate:i18n",
"generate:release-notes": "ts-node ./bin/release-note-generator.ts",
"test": "lage test --continue",
"test:debug": "lage test --no-cache --continue",
"e2e": "yarn workspace @actual-app/web run e2e",
"test": "yarn workspaces foreach --all --parallel --verbose run test",
"test:debug": "yarn workspaces foreach --all --verbose run test",
"e2e": "yarn workspaces foreach --all --exclude desktop-electron --parallel --verbose run e2e",
"e2e:desktop": "yarn build:desktop --skip-exe-build && yarn workspace desktop-electron e2e",
"playwright": "yarn workspace @actual-app/web run playwright",
"vrt": "yarn workspace @actual-app/web run vrt",
"vrt": "yarn workspaces foreach --all --parallel --verbose run vrt",
"vrt:docker": "./bin/run-vrt",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -f -m ./packages/loot-core",
"rebuild-node": "yarn workspace loot-core rebuild",
"lint": "prettier --check . && eslint . --max-warnings 0",
"lint:fix": "prettier --check --write . && eslint . --max-warnings 0 --fix",
@@ -59,34 +54,33 @@
"prepare": "husky"
},
"devDependencies": {
"@octokit/rest": "^22.0.1",
"@types/node": "^22.19.1",
"@octokit/rest": "^22.0.0",
"@types/node": "^22.17.0",
"@types/prompts": "^2.4.9",
"@typescript-eslint/parser": "^8.46.4",
"cross-env": "^10.1.0",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"@typescript-eslint/parser": "^8.42.0",
"cross-env": "^7.0.3",
"eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.5",
"eslint-import-resolver-typescript": "^4.3.5",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-hooks": "^6.0.0-rc.2",
"eslint-plugin-typescript-paths": "^0.0.33",
"globals": "^16.5.0",
"globals": "^15.15.0",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
"lage": "^2.14.15",
"lint-staged": "^16.2.6",
"minimatch": "^10.1.1",
"node-jq": "^6.3.1",
"lint-staged": "^15.5.2",
"minimatch": "^10.0.3",
"node-jq": "^6.0.1",
"npm-run-all": "^4.1.5",
"p-limit": "^7.2.0",
"prettier": "^3.6.2",
"p-limit": "^6.2.0",
"prettier": "^3.5.3",
"prompts": "^2.4.2",
"source-map-support": "^0.5.21",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.4",
"typescript": "^5.9.2",
"typescript-eslint": "^8.42.0",
"typescript-strict-plugin": "^2.4.4"
},
"resolutions": {
@@ -94,7 +88,7 @@
"socks": ">=2.8.3"
},
"engines": {
"node": ">=22",
"node": ">=20",
"yarn": "^4.9.1"
},
"lint-staged": {
@@ -103,7 +97,7 @@
"prettier --write"
]
},
"packageManager": "yarn@4.10.3",
"packageManager": "yarn@4.9.1",
"browserslist": [
"electron >= 35.0",
"defaults"

View File

@@ -1,8 +1,7 @@
// @ts-strict-ignore
import * as fs from 'fs/promises';
import * as path from 'path';
import { type RuleEntity } from 'loot-core/types/models';
import * as api from './index';
const budgetName = 'test-budget';
@@ -283,7 +282,7 @@ describe('API CRUD operations', () => {
expect(await api.getAccountBalance(accountId2)).toEqual(0);
await api.updateAccount(accountId1, { offbudget: false });
await api.closeAccount(accountId1, accountId2);
await api.closeAccount(accountId1, accountId2, null);
await api.deleteAccount(accountId2);
// accounts successfully updated, and one of them deleted
@@ -506,7 +505,7 @@ describe('API CRUD operations', () => {
...rule,
stage: 'post',
conditionsOp: 'or',
} satisfies RuleEntity;
};
expect(await api.updateRule(updatedRule)).toEqual(updatedRule);
expect(await api.getRules()).toEqual(
@@ -720,7 +719,7 @@ describe('API CRUD operations', () => {
// Test without notes
const transactionsWithoutNotes = [
{ date: '2023-11-03', imported_id: '11', amount: 100 },
{ date: '2023-11-03', imported_id: '11', amount: 100, notes: null },
];
const addResultWithoutNotes = await api.addTransactions(

View File

@@ -1,18 +1,6 @@
import type {
APIAccountEntity,
APICategoryEntity,
APICategoryGroupEntity,
APIFileEntity,
APIPayeeEntity,
APIScheduleEntity,
} from 'loot-core/server/api-models';
import type { Query } from 'loot-core/shared/query';
// @ts-strict-ignore
import type { Handlers } from 'loot-core/types/handlers';
import type {
ImportTransactionEntity,
RuleEntity,
TransactionEntity,
} from 'loot-core/types/models';
import type { ImportTransactionEntity } from 'loot-core/types/models/import-transaction';
import * as injected from './injected';
@@ -25,11 +13,8 @@ function send<K extends keyof Handlers, T extends Handlers[K]>(
return injected.send(name, args);
}
export async function runImport(
budgetName: APIFileEntity['name'],
func: () => Promise<void>,
) {
await send('api/start-import', { budgetName });
export async function runImport(name, func) {
await send('api/start-import', { budgetName: name });
try {
await func();
} catch (e) {
@@ -39,14 +24,11 @@ export async function runImport(
await send('api/finish-import');
}
export async function loadBudget(budgetId: string) {
export async function loadBudget(budgetId) {
return send('api/load-budget', { id: budgetId });
}
export async function downloadBudget(
syncId: string,
{ password }: { password?: string } = {},
) {
export async function downloadBudget(syncId, { password }: { password? } = {}) {
return send('api/download-budget', { syncId, password });
}
@@ -58,13 +40,11 @@ export async function sync() {
return send('api/sync');
}
export async function runBankSync(args?: {
accountId: APIAccountEntity['id'];
}) {
export async function runBankSync(args?: { accountId: string }) {
return send('api/bank-sync', args);
}
export async function batchBudgetUpdates(func: () => Promise<void>) {
export async function batchBudgetUpdates(func) {
await send('api/batch-budget-start');
try {
await func();
@@ -77,11 +57,11 @@ export async function batchBudgetUpdates(func: () => Promise<void>) {
* @deprecated Please use `aqlQuery` instead.
* This function will be removed in a future release.
*/
export function runQuery(query: Query) {
export function runQuery(query) {
return send('api/query', { query: query.serialize() });
}
export function aqlQuery(query: Query) {
export function aqlQuery(query) {
return send('api/query', { query: query.serialize() });
}
@@ -89,33 +69,22 @@ export function getBudgetMonths() {
return send('api/budget-months');
}
export function getBudgetMonth(month: string) {
export function getBudgetMonth(month) {
return send('api/budget-month', { month });
}
export function setBudgetAmount(
month: string,
categoryId: APICategoryEntity['id'],
value: number,
) {
export function setBudgetAmount(month, categoryId, value) {
return send('api/budget-set-amount', { month, categoryId, amount: value });
}
export function setBudgetCarryover(
month: string,
categoryId: APICategoryEntity['id'],
flag: boolean,
) {
export function setBudgetCarryover(month, categoryId, flag) {
return send('api/budget-set-carryover', { month, categoryId, flag });
}
export function addTransactions(
accountId: APIAccountEntity['id'],
transactions: Omit<ImportTransactionEntity, 'account'>[],
{
learnCategories = false,
runTransfers = false,
}: { learnCategories?: boolean; runTransfers?: boolean } = {},
accountId,
transactions,
{ learnCategories = false, runTransfers = false } = {},
) {
return send('api/transactions-add', {
accountId,
@@ -131,7 +100,7 @@ export interface ImportTransactionsOpts {
}
export function importTransactions(
accountId: APIAccountEntity['id'],
accountId: string,
transactions: ImportTransactionEntity[],
opts: ImportTransactionsOpts = {
defaultCleared: true,
@@ -146,22 +115,15 @@ export function importTransactions(
});
}
export function getTransactions(
accountId: APIAccountEntity['id'],
startDate: string,
endDate: string,
) {
export function getTransactions(accountId, startDate, endDate) {
return send('api/transactions-get', { accountId, startDate, endDate });
}
export function updateTransaction(
id: TransactionEntity['id'],
fields: Partial<TransactionEntity>,
) {
export function updateTransaction(id, fields) {
return send('api/transaction-update', { id, fields });
}
export function deleteTransaction(id: TransactionEntity['id']) {
export function deleteTransaction(id) {
return send('api/transaction-delete', { id });
}
@@ -169,25 +131,15 @@ export function getAccounts() {
return send('api/accounts-get');
}
export function createAccount(
account: Omit<APIAccountEntity, 'id'>,
initialBalance?: number,
) {
export function createAccount(account, initialBalance?) {
return send('api/account-create', { account, initialBalance });
}
export function updateAccount(
id: APIAccountEntity['id'],
fields: Partial<APIAccountEntity>,
) {
export function updateAccount(id, fields) {
return send('api/account-update', { id, fields });
}
export function closeAccount(
id: APIAccountEntity['id'],
transferAccountId?: APIAccountEntity['id'],
transferCategoryId?: APICategoryEntity['id'],
) {
export function closeAccount(id, transferAccountId?, transferCategoryId?) {
return send('api/account-close', {
id,
transferAccountId,
@@ -195,15 +147,15 @@ export function closeAccount(
});
}
export function reopenAccount(id: APIAccountEntity['id']) {
export function reopenAccount(id) {
return send('api/account-reopen', { id });
}
export function deleteAccount(id: APIAccountEntity['id']) {
export function deleteAccount(id) {
return send('api/account-delete', { id });
}
export function getAccountBalance(id: APIAccountEntity['id'], cutoff?: Date) {
export function getAccountBalance(id, cutoff?) {
return send('api/account-balance', { id, cutoff });
}
@@ -211,21 +163,15 @@ export function getCategoryGroups() {
return send('api/category-groups-get');
}
export function createCategoryGroup(group: Omit<APICategoryGroupEntity, 'id'>) {
export function createCategoryGroup(group) {
return send('api/category-group-create', { group });
}
export function updateCategoryGroup(
id: APICategoryGroupEntity['id'],
fields: Partial<APICategoryGroupEntity>,
) {
export function updateCategoryGroup(id, fields) {
return send('api/category-group-update', { id, fields });
}
export function deleteCategoryGroup(
id: APICategoryGroupEntity['id'],
transferCategoryId?: APICategoryEntity['id'],
) {
export function deleteCategoryGroup(id, transferCategoryId?) {
return send('api/category-group-delete', { id, transferCategoryId });
}
@@ -233,21 +179,15 @@ export function getCategories() {
return send('api/categories-get', { grouped: false });
}
export function createCategory(category: Omit<APICategoryEntity, 'id'>) {
export function createCategory(category) {
return send('api/category-create', { category });
}
export function updateCategory(
id: APICategoryEntity['id'],
fields: Partial<APICategoryEntity>,
) {
export function updateCategory(id, fields) {
return send('api/category-update', { id, fields });
}
export function deleteCategory(
id: APICategoryEntity['id'],
transferCategoryId?: APICategoryEntity['id'],
) {
export function deleteCategory(id, transferCategoryId?) {
return send('api/category-delete', { id, transferCategoryId });
}
@@ -259,25 +199,19 @@ export function getPayees() {
return send('api/payees-get');
}
export function createPayee(payee: Omit<APIPayeeEntity, 'id'>) {
export function createPayee(payee) {
return send('api/payee-create', { payee });
}
export function updatePayee(
id: APIPayeeEntity['id'],
fields: Partial<APIPayeeEntity>,
) {
export function updatePayee(id, fields) {
return send('api/payee-update', { id, fields });
}
export function deletePayee(id: APIPayeeEntity['id']) {
export function deletePayee(id) {
return send('api/payee-delete', { id });
}
export function mergePayees(
targetId: APIPayeeEntity['id'],
mergeIds: APIPayeeEntity['id'][],
) {
export function mergePayees(targetId, mergeIds) {
return send('api/payees-merge', { targetId, mergeIds });
}
@@ -285,39 +219,35 @@ export function getRules() {
return send('api/rules-get');
}
export function getPayeeRules(id: RuleEntity['id']) {
export function getPayeeRules(id) {
return send('api/payee-rules-get', { id });
}
export function createRule(rule: Omit<RuleEntity, 'id'>) {
export function createRule(rule) {
return send('api/rule-create', { rule });
}
export function updateRule(rule: RuleEntity) {
export function updateRule(rule) {
return send('api/rule-update', { rule });
}
export function deleteRule(id: RuleEntity['id']) {
export function deleteRule(id: string) {
return send('api/rule-delete', id);
}
export function holdBudgetForNextMonth(month: string, amount: number) {
export function holdBudgetForNextMonth(month, amount) {
return send('api/budget-hold-for-next-month', { month, amount });
}
export function resetBudgetHold(month: string) {
export function resetBudgetHold(month) {
return send('api/budget-reset-hold', { month });
}
export function createSchedule(schedule: Omit<APIScheduleEntity, 'id'>) {
export function createSchedule(schedule) {
return send('api/schedule-create', schedule);
}
export function updateSchedule(
id: APIScheduleEntity['id'],
fields: Partial<APIScheduleEntity>,
resetNextDate?: boolean,
) {
export function updateSchedule(id, fields, resetNextDate?: boolean) {
return send('api/schedule-update', {
id,
fields,
@@ -325,7 +255,7 @@ export function updateSchedule(
});
}
export function deleteSchedule(scheduleId: APIScheduleEntity['id']) {
export function deleteSchedule(scheduleId) {
return send('api/schedule-delete', scheduleId);
}
@@ -333,10 +263,7 @@ export function getSchedules() {
return send('api/schedules-get');
}
export function getIDByName(
type: 'accounts' | 'schedules' | 'categories' | 'payees',
name: string,
) {
export function getIDByName(type, name) {
return send('api/get-id-by-name', { type, name });
}

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/api",
"version": "25.11.0",
"version": "25.9.0",
"license": "MIT",
"description": "An API for Actual",
"engines": {
@@ -19,19 +19,19 @@
"build:migrations": "cp migrations/*.sql dist/migrations",
"build:default-db": "cp default-db.sqlite dist/",
"build": "yarn run clean && yarn run build:app && yarn run build:node && yarn run build:migrations && yarn run build:default-db",
"test": "yarn run build:app && yarn run build:crdt && vitest --run",
"test": "yarn run build:app && yarn run build:crdt && vitest",
"clean": "rm -rf dist @types"
},
"dependencies": {
"@actual-app/crdt": "workspace:^",
"better-sqlite3": "^12.4.1",
"better-sqlite3": "^12.2.0",
"compare-versions": "^6.1.1",
"node-fetch": "^3.3.2",
"uuid": "^13.0.0"
"uuid": "^11.1.0"
},
"devDependencies": {
"tsc-alias": "^1.8.16",
"typescript": "^5.9.3",
"vitest": "^4.0.9"
"typescript": "^5.9.2",
"vitest": "^3.2.4"
}
}

7
packages/api/utils.js Normal file
View File

@@ -0,0 +1,7 @@
export function amountToInteger(n) {
return Math.round(n * 100);
}
export function integerToAmount(n) {
return parseFloat((n / 100).toFixed(2));
}

View File

@@ -1,6 +0,0 @@
// @ts-ignore: bundle not available until we build it
// eslint-disable-next-line import/extensions
import * as bundle from './app/bundle.api.js';
export const amountToInteger = bundle.lib.amountToInteger;
export const integerToAmount = bundle.lib.integerToAmount;

View File

@@ -5,6 +5,5 @@ export default {
// print only console.error
return type === 'stderr';
},
maxWorkers: 2,
},
};

View File

@@ -1,678 +0,0 @@
#!/usr/bin/env node
/**
* Generates a combined bundle stats comment for GitHub Actions.
* Heavily inspired by https://github.com/twk3/rollup-size-compare-action (MIT).
*/
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
const REQUIRED_ARGS = new Map([
['base', 'Mapping of bundle names to base stats JSON'],
['head', 'Mapping of bundle names to head stats JSON'],
]);
function parseRawArgs(argv) {
const args = new Map();
for (let index = 2; index < argv.length; index += 1) {
const key = argv[index];
if (!key?.startsWith('--')) {
throw new Error(
`Unexpected argument “${key ?? ''}”. Use --key value pairs.`,
);
}
const values = [];
while (index + 1 < argv.length && !argv[index + 1].startsWith('--')) {
values.push(argv[index + 1]);
index += 1;
}
if (values.length === 0) {
throw new Error(`Missing value for argument “${key}”.`);
}
const keyName = key.slice(2);
// Accumulate values if the key already exists
if (args.has(keyName)) {
args.set(keyName, [...args.get(keyName), ...values]);
} else {
args.set(keyName, values);
}
}
return args;
}
function getSingleValue(args, key) {
const values = args.get(key);
if (!values) {
return undefined;
}
if (values.length !== 1) {
throw new Error(`Argument “--${key}” must have exactly one value.`);
}
return values[0];
}
function parseMapping(values, key, description) {
if (!values || values.length === 0) {
throw new Error(`Missing required argument “--${key}” (${description}).`);
}
if (values.length === 1) {
const [rawValue] = values;
const trimmed = rawValue.trim();
if (trimmed.startsWith('{')) {
try {
const parsed = JSON.parse(trimmed);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Value must be a JSON object.');
}
return new Map(
Object.entries(parsed).map(([name, pathValue]) => {
if (typeof pathValue !== 'string') {
throw new Error(
`Value for “${name}” in “--${key}” must be a string path.`,
);
}
return [name, pathValue];
}),
);
} catch (error) {
const message =
error instanceof Error ? error.message : 'Unknown parsing error';
throw new Error(
`Failed to parse “--${key}” value as JSON object: ${message}`,
);
}
}
}
const entries = new Map();
for (const value of values) {
const [rawName, ...rawPathParts] = value.split('=');
if (!rawName || rawPathParts.length === 0) {
throw new Error(
`Argument “--${key}” must be provided as name=path pairs or a JSON object.`,
);
}
const name = rawName.trim();
const pathValue = rawPathParts.join('=').trim();
if (!name) {
throw new Error(`Argument “--${key}” contains an empty bundle name.`);
}
if (!pathValue) {
throw new Error(
`Argument “--${key}” for bundle “${name}” must include a non-empty path.`,
);
}
entries.set(name, pathValue);
}
if (entries.size === 0) {
throw new Error(`Argument “--${key}” must define at least one bundle.`);
}
return entries;
}
function parseArgs(argv) {
const args = parseRawArgs(argv);
const baseMap = parseMapping(
args.get('base'),
'base',
REQUIRED_ARGS.get('base'),
);
const headMap = parseMapping(
args.get('head'),
'head',
REQUIRED_ARGS.get('head'),
);
const sections = [];
for (const [name, basePath] of baseMap.entries()) {
const headPath = headMap.get(name);
if (!headPath) {
throw new Error(
`Bundle “${name}” is missing a corresponding “--head” entry.`,
);
}
sections.push({
name,
basePath,
headPath,
});
}
for (const name of headMap.keys()) {
if (!baseMap.has(name)) {
throw new Error(
`Bundle “${name}” is missing a corresponding “--base” entry.`,
);
}
}
return {
sections,
identifier: getSingleValue(args, 'identifier') ?? 'bundle-stats',
};
}
async function loadStats(filePath) {
try {
const absolutePath = path.resolve(process.cwd(), filePath);
const fileContents = await readFile(absolutePath, 'utf8');
const parsed = JSON.parse(fileContents);
// Validate that we got a meaningful stats object
if (!parsed || typeof parsed !== 'object') {
throw new Error('Stats file does not contain a valid JSON object');
}
return parsed;
} catch (error) {
const message =
error instanceof Error
? error.message
: 'Unknown error while parsing stats file';
console.error(`[bundle-stats] Failed to parse “${filePath}”: ${message}`);
throw new Error(`Failed to load stats file “${filePath}”: ${message}`);
}
}
function findAllChildren(node = {}) {
if (Array.isArray(node.children)) {
return node.children.flatMap(findAllChildren);
}
return [node];
}
function trimPath(input) {
if (!input) {
return '';
}
return input.replace(/.*node_modules/, '/node_modules');
}
function assetNameToSizeMap(statAssets = {}) {
const children = statAssets?.tree?.children;
if (!Array.isArray(children) || children.length === 0) {
return new Map();
}
return new Map(
children.map(asset => {
const descendants = findAllChildren(asset);
let size = 0;
let gzipSize = statAssets?.options?.gzip ? 0 : null;
for (const mod of descendants) {
const nodePart = statAssets?.nodeParts?.[mod.uid];
if (!nodePart) {
continue;
}
size += nodePart.renderedLength ?? 0;
if (gzipSize !== null) {
gzipSize += nodePart.gzipLength ?? 0;
}
}
return [trimPath(asset.name), { size, gzipSize }];
}),
);
}
function chunkModuleNameToSizeMap(statChunks = {}) {
if (!statChunks?.tree) {
return new Map();
}
return new Map(
findAllChildren(statChunks.tree).map(mod => {
const modInfo = statChunks?.nodeParts?.[mod.uid] ?? {};
const meta = statChunks?.nodeMetas?.[modInfo.metaUid] ?? {};
const id = trimPath(meta.id ?? '');
return [
id,
{
size: modInfo.renderedLength ?? 0,
gzipSize: statChunks?.options?.gzip
? (modInfo.gzipLength ?? 0)
: null,
},
];
}),
);
}
function sortDiffDescending(items) {
return items.sort((a, b) => Math.abs(b.diff) - Math.abs(a.diff));
}
function normaliseGzip(value) {
if (value == null || Number.isNaN(value)) {
return NaN;
}
return value;
}
function getAssetDiff(name, oldSize, newSize) {
const diff = newSize.size - oldSize.size;
const percent =
oldSize.size === 0
? newSize.size === 0
? 0
: Infinity
: +((1 - newSize.size / oldSize.size) * -100).toFixed(5) || 0;
return {
name,
new: {
size: newSize.size,
gzipSize: normaliseGzip(newSize.gzipSize),
},
old: {
size: oldSize.size,
gzipSize: normaliseGzip(oldSize.gzipSize),
},
diff,
diffPercentage: percent,
};
}
function webpackStatsDiff(oldAssets, newAssets) {
const added = [];
const removed = [];
const bigger = [];
const smaller = [];
const unchanged = [];
let newSizeTotal = 0;
let oldSizeTotal = 0;
let newGzipSizeTotal = 0;
let oldGzipSizeTotal = 0;
for (const [name, oldAssetSizes] of oldAssets) {
oldSizeTotal += oldAssetSizes.size;
oldGzipSizeTotal += oldAssetSizes.gzipSize ?? NaN;
const newAsset = newAssets.get(name);
if (!newAsset) {
removed.push(getAssetDiff(name, oldAssetSizes, { size: 0, gzipSize: 0 }));
continue;
}
const diff = getAssetDiff(name, oldAssetSizes, newAsset);
if (diff.diffPercentage > 0) {
bigger.push(diff);
} else if (diff.diffPercentage < 0) {
smaller.push(diff);
} else {
unchanged.push(diff);
}
}
for (const [name, newAssetSizes] of newAssets) {
newSizeTotal += newAssetSizes.size;
newGzipSizeTotal += newAssetSizes.gzipSize ?? NaN;
if (!oldAssets.has(name)) {
added.push(getAssetDiff(name, { size: 0, gzipSize: 0 }, newAssetSizes));
}
}
const oldFilesCount = oldAssets.size;
const newFilesCount = newAssets.size;
return {
added: sortDiffDescending(added),
removed: sortDiffDescending(removed),
bigger: sortDiffDescending(bigger),
smaller: sortDiffDescending(smaller),
unchanged,
total: getAssetDiff(
oldFilesCount === newFilesCount
? `${newFilesCount}`
: `${oldFilesCount}${newFilesCount}`,
{ size: oldSizeTotal, gzipSize: oldGzipSizeTotal },
{ size: newSizeTotal, gzipSize: newGzipSizeTotal },
),
};
}
function getStatsDiff(oldStats, newStats) {
return webpackStatsDiff(
assetNameToSizeMap(oldStats),
assetNameToSizeMap(newStats),
);
}
function getChunkModuleDiff(oldStats, newStats) {
const diff = webpackStatsDiff(
chunkModuleNameToSizeMap(oldStats),
chunkModuleNameToSizeMap(newStats),
);
if (
diff.added.length === 0 &&
diff.removed.length === 0 &&
diff.bigger.length === 0 &&
diff.smaller.length === 0
) {
return null;
}
return diff;
}
const BYTES_PER_KILOBYTE = 1024;
const FILE_SIZE_DENOMINATIONS = [
'B',
'kB',
'MB',
'GB',
'TB',
'PB',
'EB',
'ZB',
'YB',
'BB',
];
function formatFileSizeIEC(bytes, precision = 2) {
if (bytes == null || Number.isNaN(bytes)) {
return 'N/A';
}
if (bytes === 0) {
return `0 ${FILE_SIZE_DENOMINATIONS[0]}`;
}
const absBytes = Math.abs(bytes);
const denominationIndex = Math.floor(
Math.log(absBytes) / Math.log(BYTES_PER_KILOBYTE),
);
const value = absBytes / Math.pow(BYTES_PER_KILOBYTE, denominationIndex);
const stripped = parseFloat(value.toFixed(precision));
return `${stripped} ${FILE_SIZE_DENOMINATIONS[denominationIndex]}`;
}
function conditionalPercentage(number) {
if (number === Infinity || number === -Infinity) {
return '-';
}
const absValue = Math.abs(number);
if (absValue === 0 || absValue === 100) {
return `${number}%`;
}
const value = Number.isFinite(absValue) ? absValue.toFixed(2) : absValue;
return `${signFor(number)}${value}%`;
}
function capitalize(text) {
if (!text) return '';
return `${text[0].toUpperCase()}${text.slice(1)}`;
}
function makeHeader(columns) {
const header = columns.join(' | ');
const separator = columns
.map(column =>
Array.from({ length: column.length })
.map(() => '-')
.join(''),
)
.join(' | ');
return `${header}\n${separator}`;
}
const TOTAL_HEADERS = makeHeader([
'Files count',
'Total bundle size',
'% Changed',
]);
const TABLE_HEADERS = makeHeader(['Asset', 'File Size', '% Changed']);
const CHUNK_TABLE_HEADERS = makeHeader(['File', 'Δ', 'Size']);
function signFor(num) {
if (num === 0) return '';
return num > 0 ? '+' : '-';
}
function toFileSizeDiff(oldSize, newSize, diff) {
const diffLine = [
`${formatFileSizeIEC(oldSize)}${formatFileSizeIEC(newSize)}`,
];
if (typeof diff !== 'undefined') {
diffLine.push(`(${signFor(diff)}${formatFileSizeIEC(diff)})`);
}
return diffLine.join(' ');
}
function toFileSizeDiffCell(asset) {
const lines = [];
if (asset.diff === 0) {
lines.push(formatFileSizeIEC(asset.new.size));
if (asset.new.gzipSize) {
lines.push(formatFileSizeIEC(asset.new.gzipSize));
}
} else {
lines.push(toFileSizeDiff(asset.old.size, asset.new.size, asset.diff));
if (asset.old.gzipSize || asset.new.gzipSize) {
lines.push(
`${toFileSizeDiff(asset.old.gzipSize, asset.new.gzipSize)} (gzip)`,
);
}
}
return lines.join('<br />');
}
function printAssetTableRow(asset) {
return [
asset.name,
toFileSizeDiffCell(asset),
conditionalPercentage(asset.diffPercentage),
].join(' | ');
}
function printAssetTablesByGroup(statsDiff) {
const statsFields = ['added', 'removed', 'bigger', 'smaller', 'unchanged'];
return statsFields
.map(field => {
const assets = statsDiff[field] ?? [];
if (assets.length === 0) {
return `**${capitalize(field)}**\nNo assets were ${field}`;
}
return `**${capitalize(field)}**\n${TABLE_HEADERS}\n${assets
.map(asset => printAssetTableRow(asset))
.join('\n')}`;
})
.join('\n\n');
}
function getDiffEmoji(diff) {
if (diff.diffPercentage === Infinity) return '🆕';
if (diff.diffPercentage <= -100) return '🔥';
if (diff.diffPercentage > 0) return '📈';
if (diff.diffPercentage < 0) return '📉';
return ' ';
}
function getTrimmedChunkName(chunkModule) {
const chunkName = chunkModule.name ?? '';
if (chunkName.startsWith('./')) {
return chunkName.substring(2);
}
if (chunkName.startsWith('/')) {
return chunkName.substring(1);
}
return chunkName;
}
function printChunkModuleRow(chunkModule) {
const emoji = getDiffEmoji(chunkModule);
const chunkName = getTrimmedChunkName(chunkModule);
const diffPart = `${chunkModule.diff >= 0 ? '+' : '-'}${formatFileSizeIEC(chunkModule.diff)}`;
const percentPart = Number.isFinite(chunkModule.diffPercentage)
? ` (${conditionalPercentage(chunkModule.diffPercentage)})`
: '';
return [
`\`${chunkName}\``,
`${emoji} ${diffPart}${percentPart}`,
`${formatFileSizeIEC(chunkModule.old.size)}${formatFileSizeIEC(chunkModule.new.size)}`,
].join(' | ');
}
function printChunkModulesTable(statsDiff) {
if (!statsDiff) {
return '';
}
const changedModules = [
...(statsDiff.added ?? []),
...(statsDiff.removed ?? []),
...(statsDiff.bigger ?? []),
...(statsDiff.smaller ?? []),
].sort((a, b) => b.diffPercentage - a.diffPercentage);
if (changedModules.length === 0) {
return `<details>\n<summary>Changeset</summary>\nNo files were changed\n</details>`;
}
const rows = changedModules
.slice(0, 100)
.map(chunkModule => printChunkModuleRow(chunkModule))
.join('\n');
const summarySuffix =
changedModules.length > 100 ? ' (largest 100 files by percent change)' : '';
return `<details>\n<summary>Changeset${summarySuffix}</summary>\n\n${CHUNK_TABLE_HEADERS}\n${rows}\n</details>`;
}
function printTotalAssetTable(statsDiff) {
return `**Total**\n${TOTAL_HEADERS}\n${printAssetTableRow(statsDiff.total)}`;
}
function renderSection(title, statsDiff, chunkModuleDiff) {
const { total, ...groups } = statsDiff;
const parts = [`#### ${title}`, '', printTotalAssetTable({ total })];
const chunkTable = printChunkModulesTable(chunkModuleDiff);
if (chunkTable) {
parts.push('', chunkTable);
}
parts.push(
'',
`<details>\n<summary>View detailed bundle breakdown</summary>\n<div>\n\n${printAssetTablesByGroup(
groups,
)}\n</div>\n</details>`,
);
return parts.join('\n');
}
async function main() {
const args = parseArgs(process.argv);
console.error(
`[bundle-stats] Found ${args.sections.length} sections to process`,
);
args.sections.forEach((section, index) => {
console.error(
`[bundle-stats] Section ${index + 1}: ${section.name} (base: ${section.basePath}, head: ${section.headPath})`,
);
});
const sections = [];
for (const section of args.sections) {
console.error(`[bundle-stats] Processing section: ${section.name}`);
console.error(
`[bundle-stats] Loading base stats from: ${section.basePath}`,
);
const baseStats = await loadStats(section.basePath);
console.error(
`[bundle-stats] Loading head stats from: ${section.headPath}`,
);
const headStats = await loadStats(section.headPath);
const statsDiff = getStatsDiff(baseStats, headStats);
const chunkDiff = getChunkModuleDiff(baseStats, headStats);
console.error(
`[bundle-stats] Section ${section.name}: ${statsDiff.total.name} files, total size ${statsDiff.total.old.size}${statsDiff.total.new.size}`,
);
sections.push({
name: section.name,
statsDiff,
chunkDiff,
});
}
const identifier = `<!--- bundlestats-action-comment key:${args.identifier} --->`;
const comment = [
'### Bundle Stats',
'',
sections
.map(section =>
renderSection(section.name, section.statsDiff, section.chunkDiff),
)
.join('\n\n---\n\n'),
'',
identifier,
'',
].join('\n');
process.stdout.write(comment);
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
});

View File

@@ -1,151 +0,0 @@
#!/usr/bin/env node
/**
* Updates (or creates) a bundle stats comment on a pull request.
* Requires the following environment variables to be set:
* - GITHUB_TOKEN
* - GITHUB_REPOSITORY (owner/repo)
* - PR_NUMBER
*/
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { Octokit } from '@octokit/rest';
function parseArgs(argv) {
const args = {
commentFile: null,
identifier: null,
};
for (let i = 2; i < argv.length; i += 2) {
const key = argv[i];
const value = argv[i + 1];
if (!key?.startsWith('--')) {
throw new Error(
`Unexpected argument “${key ?? ''}”. Use --key value pairs.`,
);
}
if (typeof value === 'undefined') {
throw new Error(`Missing value for argument “${key}”.`);
}
switch (key) {
case '--comment-file':
args.commentFile = value;
break;
case '--identifier':
args.identifier = value;
break;
default:
throw new Error(`Unknown argument “${key}”.`);
}
}
if (!args.commentFile) {
throw new Error('Missing required argument “--comment-file“.');
}
if (!args.identifier) {
throw new Error('Missing required argument “--identifier“.');
}
return args;
}
async function loadCommentBody(commentFile) {
const absolutePath = path.resolve(process.cwd(), commentFile);
return readFile(absolutePath, 'utf8');
}
function getRepoInfo() {
const repository = process.env.GITHUB_REPOSITORY;
if (!repository) {
throw new Error('GITHUB_REPOSITORY environment variable is required.');
}
const [owner, repo] = repository.split('/');
if (!owner || !repo) {
throw new Error(`Invalid GITHUB_REPOSITORY value “${repository}”.`);
}
return { owner, repo };
}
function getPullRequestNumber() {
const rawNumber = process.env.PR_NUMBER ?? '';
const prNumber = Number.parseInt(rawNumber, 10);
if (!Number.isInteger(prNumber) || prNumber <= 0) {
throw new Error(
'PR_NUMBER environment variable must be a positive integer.',
);
}
return prNumber;
}
function assertGitHubToken() {
const token = process.env.GITHUB_TOKEN;
if (!token) {
throw new Error('GITHUB_TOKEN environment variable is required.');
}
return token;
}
async function listComments(octokit, owner, repo, issueNumber) {
return octokit.paginate(octokit.rest.issues.listComments, {
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
}
function isGitHubActionsBot(comment) {
return comment.user?.login === 'github-actions[bot]';
}
async function main() {
const { commentFile, identifier } = parseArgs(process.argv);
const commentBody = await loadCommentBody(commentFile);
const token = assertGitHubToken();
const { owner, repo } = getRepoInfo();
const issueNumber = getPullRequestNumber();
const octokit = new Octokit({ auth: token });
const comments = await listComments(octokit, owner, repo, issueNumber);
const existingComment = comments.find(
comment =>
isGitHubActionsBot(comment) && comment.body?.includes(identifier),
);
if (existingComment) {
await octokit.rest.issues.updateComment({
owner,
repo,
comment_id: existingComment.id,
body: commentBody,
});
console.log('Updated existing bundle stats comment.');
} else {
await octokit.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: commentBody,
});
console.log('Created new bundle stats comment.');
}
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
});

View File

@@ -3,9 +3,9 @@
"private": true,
"type": "module",
"devDependencies": {
"vitest": "^4.0.9"
"vitest": "^3.2.4"
},
"scripts": {
"test": "vitest --run"
"test": "vitest"
}
}

View File

@@ -5,7 +5,5 @@ export default defineConfig({
globals: true,
include: ['src/**/*.test.(js|jsx|ts|tsx)'],
environment: 'node',
maxWorkers: 1,
isolate: false,
},
});

View File

@@ -8,15 +8,15 @@
},
"dependencies": {
"@emotion/css": "^11.13.5",
"react-aria-components": "^1.13.0",
"react-aria-components": "^1.8.0",
"usehooks-ts": "^3.1.1"
},
"devDependencies": {
"@svgr/cli": "^8.1.0",
"@types/react": "^19.2.5",
"react": "19.2.0",
"react-dom": "19.2.0",
"vitest": "^4.0.9"
"@types/react": "^19.1.12",
"react": "19.1.1",
"react-dom": "19.1.1",
"vitest": "^3.2.4"
},
"exports": {
"./hooks/*": "./src/hooks/*.ts",
@@ -40,6 +40,7 @@
"./popover": "./src/Popover.tsx",
"./select": "./src/Select.tsx",
"./space-between": "./src/SpaceBetween.tsx",
"./stack": "./src/Stack.tsx",
"./styles": "./src/styles.ts",
"./text": "./src/Text.tsx",
"./text-one-line": "./src/TextOneLine.tsx",
@@ -48,11 +49,12 @@
"./toggle": "./src/Toggle.tsx",
"./tooltip": "./src/Tooltip.tsx",
"./view": "./src/View.tsx",
"./color-picker": "./src/ColorPicker.tsx"
"./color-picker": "./src/ColorPicker.tsx",
"./props/*": "./src/props/*.ts"
},
"scripts": {
"generate:icons": "rm src/icons/*/*.tsx; cd src/icons && svgr --template template.ts --index-template index-template.ts --typescript --expand-props start -d . .",
"test": "npm-run-all -cp 'test:*'",
"test:web": "ENV=web vitest --run -c vitest.web.config.ts"
"test:web": "ENV=web vitest -c vitest.web.config.ts"
}
}

View File

@@ -4,7 +4,6 @@ import {
isValidElement,
type ReactElement,
Ref,
RefObject,
useEffect,
useRef,
} from 'react';
@@ -12,20 +11,15 @@ import {
type InitialFocusProps<T extends HTMLElement> = {
/**
* The child element to focus when the component mounts. This can be either a single React element or a function that returns a React element.
* The child element should have a `ref` prop for this to work. For child components which receives a ref via another prop
* e.g. `inputRef`, use a function as child and pass the ref to the appropriate prop.
*/
children:
| ReactElement<{ ref: Ref<T> }>
| ((ref: RefObject<T | null>) => ReactElement);
children: ReactElement<{ ref: Ref<T> }> | ((ref: Ref<T>) => ReactElement);
};
/**
* InitialFocus sets focus on its child element
* when it mounts.
* @param {ReactElement | function} children - A single React element or a function that returns a React element.
* The child element should have a `ref` prop for this to work. For child components which receives a ref via another prop
* e.g. `inputRef`, use a function as child and pass the ref to the appropriate prop.
* @param {Object} props - The component props.
* @param {ReactElement | function} props.children - A single React element or a function that returns a React element.
*/
export function InitialFocus<T extends HTMLElement = HTMLElement>({
children,

View File

@@ -34,7 +34,6 @@ export const Popover = ({
return (
<ReactAriaPopover
data-popover={true}
ref={ref}
placement="bottom end"
offset={1}

View File

@@ -6,8 +6,6 @@ import { View } from './View';
type SpaceBetweenProps = {
direction?: 'horizontal' | 'vertical';
gap?: number;
wrap?: boolean;
align?: 'start' | 'center' | 'end' | 'stretch';
style?: CSSProperties;
children: ReactNode;
};
@@ -15,22 +13,18 @@ type SpaceBetweenProps = {
export const SpaceBetween = ({
direction = 'horizontal',
gap = 15,
wrap = true,
align = 'center',
style,
children,
...props
}: SpaceBetweenProps) => {
return (
<View
style={{
flexWrap: wrap ? 'wrap' : 'nowrap',
flexWrap: 'wrap',
flexDirection: direction === 'horizontal' ? 'row' : 'column',
alignItems: align,
alignItems: 'center',
gap,
...style,
}}
{...props}
>
{children}
</View>

View File

@@ -0,0 +1,105 @@
// @ts-strict-ignore
import React, {
Children,
type ComponentProps,
Fragment,
cloneElement,
forwardRef,
type ReactNode,
type CSSProperties,
} from 'react';
import { Text } from './Text';
import { View } from './View';
function getChildren(key, children) {
return Children.toArray(children).reduce(
(list, child) => {
if (child) {
if (
typeof child === 'object' &&
'type' in child &&
child.type === Fragment
) {
return list.concat(
getChildren(
child.key,
typeof child.props === 'object' && 'children' in child.props
? child.props.children
: [],
),
);
}
list.push({ key: key + child['key'], child });
return list;
}
return list;
},
[] as Array<{ key: string; child: ReactNode }>,
);
}
type StackProps = ComponentProps<typeof View> & {
direction?: CSSProperties['flexDirection'];
align?: string;
justify?: string;
spacing?: number;
debug?: boolean;
};
export const Stack = forwardRef<HTMLDivElement, StackProps>(
(
{
direction = 'column',
align,
justify,
spacing = 3,
children,
debug,
style,
...props
},
ref,
) => {
const isReversed = direction.endsWith('reverse');
const isHorizontal = direction.startsWith('row');
const validChildren = getChildren('', children);
return (
<View
style={{
flexDirection: direction,
alignItems: align,
justifyContent: justify,
...style,
}}
innerRef={ref}
{...props}
>
{validChildren.map(({ key, child }, index) => {
const isLastChild = validChildren.length === index + 1;
let marginProp;
if (isHorizontal) {
marginProp = isReversed ? 'marginLeft' : 'marginRight';
} else {
marginProp = isReversed ? 'marginTop' : 'marginBottom';
}
return cloneElement(
typeof child === 'string' ? <Text>{child}</Text> : child,
{
key,
style: {
...(debug && { borderWidth: 1, borderColor: 'red' }),
...(isLastChild ? null : { [marginProp]: spacing * 5 }),
...(child.props ? child.props.style : null),
},
},
);
})}
</View>
);
},
);
Stack.displayName = 'Stack';

View File

@@ -15,14 +15,12 @@ type TooltipProps = Partial<ComponentProps<typeof AriaTooltip>> & {
children: ReactNode;
content: ReactNode;
triggerProps?: Partial<ComponentProps<typeof TooltipTrigger>>;
disablePointerEvents?: boolean;
};
export const Tooltip = ({
children,
content,
triggerProps = {},
disablePointerEvents = false,
...props
}: TooltipProps) => {
const triggerRef = useRef(null);
@@ -71,14 +69,7 @@ export const Tooltip = ({
>
{children}
<AriaTooltip
triggerRef={triggerRef}
style={{
...styles.tooltip,
...(disablePointerEvents && { pointerEvents: 'none' }),
}}
{...props}
>
<AriaTooltip triggerRef={triggerRef} style={styles.tooltip} {...props}>
{content}
</AriaTooltip>
</TooltipTrigger>

View File

@@ -1,6 +1,7 @@
// @ts-strict-ignore
import path from 'path';
function indexTemplate(filePaths: { path: string }[]) {
function indexTemplate(filePaths) {
const exportEntries = filePaths.map(({ path: filePath }) => {
const basename = path.basename(filePath, path.extname(filePath));
const exportName = `Svg${basename}`;

View File

@@ -1,6 +1,5 @@
import { type Config } from '@svgr/core';
const tmpl: Config['template'] = ({ imports, interfaces, componentName, props, jsx }, { tpl }) => {
// @ts-strict-ignore
const tmpl = ({ imports, interfaces, componentName, props, jsx }, { tpl }) => {
return tpl`
${imports};

View File

@@ -0,0 +1,11 @@
import { CSSProperties } from '../styles';
export type BasicModalProps = {
isLoading?: boolean;
noAnimation?: boolean;
style?: CSSProperties;
onClose?: () => void;
containerProps?: {
style?: CSSProperties;
};
};

View File

@@ -154,10 +154,4 @@ export const styles: Record<string, any> = {
borderRadius: 4,
padding: '3px 5px',
},
mobileListItem: {
borderBottom: `1px solid ${theme.tableBorder}`,
backgroundColor: theme.tableBackground,
padding: 16,
cursor: 'pointer',
},
};

View File

@@ -21,7 +21,6 @@ export default defineConfig({
environment: 'jsdom',
globals: true,
include: ['src/**/*.web.test.(js|jsx|ts|tsx)'],
maxWorkers: 2,
},
resolve: {
alias: [

View File

@@ -12,18 +12,18 @@
"build:node": "tsc --p tsconfig.dist.json",
"proto:generate": "./bin/generate-proto",
"build": "rm -rf dist && yarn run build:node",
"test": "vitest --run --globals"
"test": "vitest --globals"
},
"dependencies": {
"google-protobuf": "^3.21.4",
"murmurhash": "^2.0.1",
"uuid": "^13.0.0"
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/google-protobuf": "3.15.12",
"protoc-gen-js": "3.21.4-4",
"ts-protoc-gen": "0.15.0",
"typescript": "^5.9.3",
"vitest": "^4.0.9"
"@types/google-protobuf": "^3.15.12",
"protoc-gen-js": "^3.21.4-4",
"ts-protoc-gen": "^0.15.0",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
}
}

View File

@@ -12,5 +12,5 @@
"outDir": "dist"
},
"include": ["."],
"exclude": ["dist", "**/*.test.ts", "**/*.spec.ts"]
"exclude": ["dist"]
}

View File

@@ -14,9 +14,6 @@ build-electron
build-stats
stats.json
# generated service worker
service-worker/
# misc
.DS_Store
.env
@@ -31,6 +28,3 @@ public/*.wasm
# translations
locale/
# service worker build output
dev-dist

View File

@@ -65,10 +65,10 @@ Run manually:
```sh
# Run docker container
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.56.0-jammy /bin/bash
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.52.0-jammy /bin/bash
# If you receive an error such as "docker: invalid reference format", please instead use the following command:
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.56.0-jammy /bin/bash
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.52.0-jammy /bin/bash
# Once inside the docker container, run the VRT tests: important - they MUST be ran against a HTTPS server.
# Use the ip and port noted earlier

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -123,14 +123,11 @@ test.describe('Accounts', () => {
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(join(__dirname, 'data/test.csv'));
if (screenshot) await expect(page).toMatchThemeScreenshots();
const importButton = accountPage.page.getByRole('button', {
name: /Import \d+ transactions/,
});
await importButton.waitFor({ state: 'visible' });
if (screenshot) await expect(page).toMatchThemeScreenshots();
await importButton.click();
await expect(importButton).not.toBeVisible();
@@ -149,14 +146,12 @@ test.describe('Accounts', () => {
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(join(__dirname, 'data/test.csv'));
await expect(page).toMatchThemeScreenshots();
const importButton = accountPage.page.getByRole('button', {
name: /Import \d+ transactions/,
});
await importButton.waitFor({ state: 'visible' });
await expect(page).toMatchThemeScreenshots();
await expect(importButton).toBeDisabled();
await expect(await importButton.innerText()).toMatch(
/Import 0 transactions/,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 137 KiB

View File

@@ -1,64 +0,0 @@
import { type Page } from '@playwright/test';
import { expect, test } from './fixtures';
import { ConfigurationPage } from './page-models/configuration-page';
import { type MobileBankSyncPage } from './page-models/mobile-bank-sync-page';
import { MobileNavigation } from './page-models/mobile-navigation';
test.describe('Mobile Bank Sync', () => {
let page: Page;
let navigation: MobileNavigation;
let bankSyncPage: MobileBankSyncPage;
let configurationPage: ConfigurationPage;
test.beforeEach(async ({ browser }) => {
page = await browser.newPage();
navigation = new MobileNavigation(page);
configurationPage = new ConfigurationPage(page);
await page.setViewportSize({
width: 350,
height: 600,
});
await page.goto('/');
await configurationPage.createTestFile();
bankSyncPage = await navigation.goToBankSyncPage();
});
test.afterEach(async () => {
await page.close();
});
test('checks the page visuals', async () => {
await bankSyncPage.waitToLoad();
await expect(
page.getByRole('heading', { name: 'Bank Sync' }),
).toBeVisible();
await expect(bankSyncPage.searchBox).toBeVisible();
await expect(bankSyncPage.searchBox).toHaveAttribute(
'placeholder',
'Filter accounts…',
);
await expect(page).toMatchThemeScreenshots();
});
test('searches for accounts', async () => {
await bankSyncPage.searchFor('Checking');
await expect(bankSyncPage.searchBox).toHaveValue('Checking');
await expect(page).toMatchThemeScreenshots();
});
test('page handles empty state gracefully', async () => {
await bankSyncPage.searchFor('NonExistentAccount123456789');
const emptyMessage = page.getByText(/No accounts found/);
await expect(emptyMessage).toBeVisible();
await expect(page).toMatchThemeScreenshots();
});
});

Some files were not shown because too many files have changed in this diff Show More