Compare commits

..

2 Commits

Author SHA1 Message Date
Matiss Janis Aboltins
a7afe7ef97 "Claude Code Review workflow" 2026-03-05 19:17:53 +00:00
Matiss Janis Aboltins
7bf27ddcb1 "Claude PR Assistant workflow" 2026-03-05 19:17:52 +00:00
22 changed files with 169 additions and 255 deletions

View File

@@ -0,0 +1,44 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options

50
.github/workflows/claude.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'

View File

@@ -87,8 +87,8 @@ jobs:
- name: Test that the docker image boots
run: |
docker run --detach --network=host actualbudget/actual-server-testing
sleep 10
curl --fail -sS -LI -w '%{http_code}\n' --retry 20 --retry-delay 1 --retry-connrefused localhost:5006
sleep 5
curl --fail -sS -LI -w '%{http_code}\n' --retry 10 --retry-delay 1 --retry-connrefused localhost:5006
# 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/

View File

@@ -1,32 +1,35 @@
import { exec } from 'node:child_process';
import { existsSync, writeFileSync } from 'node:fs';
import { existsSync, writeFile } from 'node:fs';
import { exit } from 'node:process';
import prompts from 'prompts';
async function run() {
const hasGhCli = await checkGhCli();
const username = hasGhCli ? await execAsync("gh api user --jq '.login'") : '';
if (!username) {
console.log(
'Tip: Install the GitHub CLI (https://github.com/cli/cli) and run `gh auth login` to enable auto-detection of your username and auto-creation of draft PRs.',
);
}
const username = await execAsync(
"gh api user --jq '.login'",
'To avoid having to enter your username, consider installing the official GitHub CLI (https://github.com/cli/cli) and logging in with `gh auth login`.',
);
const activePr = await getActivePr(username);
if (activePr) {
console.log(`Found existing PR #${activePr.number}: ${activePr.title}`);
console.log(
`Found potentially matching PR ${activePr.number}: ${activePr.title}`,
);
}
const initialPrNumber = activePr?.number ?? (await getNextPrNumber());
// Ask for category and summary first (before PR number)
const initial = await prompts([
const result = await prompts([
{
name: 'githubUsername',
message: 'Comma-separated GitHub username(s)',
type: 'text',
initial: username,
},
{
name: 'pullRequestNumber',
message: 'PR Number',
type: 'number',
initial: initialPrNumber,
},
{
name: 'releaseNoteType',
message: 'Release Note Type',
@@ -47,65 +50,28 @@ async function run() {
]);
if (
!initial.githubUsername ||
!initial.oneLineSummary ||
initial.releaseNoteType === undefined
!result.githubUsername ||
!result.oneLineSummary ||
!result.releaseNoteType ||
!result.pullRequestNumber
) {
console.log('All questions must be answered. Exiting');
exit(1);
}
// Determine PR number: use existing PR, offer to create draft, or ask manually
let prNumber: number;
if (activePr) {
prNumber = activePr.number;
} else if (hasGhCli) {
const { action } = await prompts({
name: 'action',
message: 'No existing PR found. How would you like to get the PR number?',
type: 'select',
choices: [
{
title: '🚀 Create a draft PR automatically',
value: 'create-draft',
description:
'Creates a draft PR using the GitHub CLI and uses its number',
},
{
title: '✏️ Enter PR number manually',
value: 'manual',
description: 'Enter a PR number you already know',
},
],
});
if (!action) {
console.log('Exiting');
exit(1);
}
if (action === 'create-draft') {
prNumber = await createDraftPr(initial.oneLineSummary);
} else {
prNumber = await askForPrNumber();
}
} else {
prNumber = await askForPrNumber();
}
const fileContents = getFileContents(
initial.releaseNoteType,
initial.githubUsername,
initial.oneLineSummary,
result.releaseNoteType,
result.githubUsername,
result.oneLineSummary,
);
const prNumber = result.pullRequestNumber;
const filepath = `./upcoming-release-notes/${prNumber}.md`;
if (existsSync(filepath)) {
const { confirm } = await prompts({
name: 'confirm',
type: 'confirm',
message: `This will overwrite the existing release note ${filepath}. Are you sure?`,
message: `This will overwrite the existing release note ${filepath} Are you sure?`,
});
if (!confirm) {
console.log('Exiting');
@@ -113,79 +79,14 @@ async function run() {
}
}
writeFileSync(filepath, fileContents);
console.log(`Release note generated successfully: ${filepath}`);
}
async function checkGhCli(): Promise<boolean> {
const result = await execAsync('gh --version');
return result !== '';
}
async function createDraftPr(title: string): Promise<number> {
// Ensure current branch is pushed to remote
const branchName = await execAsync('git rev-parse --abbrev-ref HEAD');
if (!branchName || branchName === 'master' || branchName === 'main') {
console.error(
'Cannot create a draft PR from the main/master branch. Please switch to a feature branch first.',
);
exit(1);
}
console.log(`Pushing branch "${branchName}" to remote...`);
const pushResult = await execAsync(
`git push -u origin ${branchName} 2>&1`,
'Failed to push branch to remote. Please push manually first.',
);
if (pushResult === '') {
// execAsync returns '' on error
exit(1);
}
console.log('Creating draft PR...');
const prUrl = await execAsync(
`gh pr create --draft --title "${title.replace(/"/g, '\\"')}" --body "" 2>&1`,
'Failed to create draft PR. Please create one manually via GitHub.',
);
if (!prUrl) {
exit(1);
}
// Extract PR number from URL (e.g., https://github.com/owner/repo/pull/1234)
const prNumberMatch = prUrl.match(/\/pull\/(\d+)/);
if (!prNumberMatch) {
// gh pr create might return just a number or other format
const numberMatch = prUrl.match(/(\d+)/);
if (!numberMatch) {
console.error('Could not parse PR number from output:', prUrl);
writeFile(filepath, fileContents, err => {
if (err) {
console.error('Failed to write release note file:', err);
exit(1);
} else {
console.log(`Release note generated successfully: ${filepath}`);
}
const prNumber = parseInt(numberMatch[1], 10);
console.log(`Draft PR #${prNumber} created: ${prUrl.trim()}`);
return prNumber;
}
const prNumber = parseInt(prNumberMatch[1], 10);
console.log(`Draft PR #${prNumber} created: ${prUrl.trim()}`);
return prNumber;
}
async function askForPrNumber(): Promise<number> {
const nextPrNumber = await getNextPrNumber();
const { pullRequestNumber } = await prompts({
name: 'pullRequestNumber',
message: 'PR Number',
type: 'number',
initial: nextPrNumber,
});
if (!pullRequestNumber) {
console.log('PR number is required. Exiting');
exit(1);
}
return pullRequestNumber;
}
// makes an attempt to find an existing open PR from <username>:<branch>
@@ -268,9 +169,7 @@ async function execAsync(cmd: string, errorLog?: string): Promise<string> {
return new Promise<string>(res => {
exec(cmd, (error, stdout) => {
if (error) {
if (errorLog) {
console.log(errorLog);
}
console.log(errorLog);
res('');
} else {
res(stdout.trim());

View File

@@ -49,7 +49,6 @@ export function FormulaResult({
containerRef,
}: FormulaResultProps) {
const [fontSize, setFontSize] = useState<number>(initialFontSize);
const [hasSized, setHasSized] = useState(false);
const refDiv = useRef<HTMLDivElement>(null);
const previousFontSizeRef = useRef<number>(initialFontSize);
const format = useFormat();
@@ -90,10 +89,7 @@ export function FormulaResult({
height, // Ensure the text fits vertically by using the height as the limiting factor
);
if (calculatedFontSize > 0) {
setFontSize(calculatedFontSize);
setHasSized(true);
}
setFontSize(calculatedFontSize);
// Only call fontSizeChanged if the font size actually changed
if (
@@ -147,7 +143,6 @@ export function FormulaResult({
useEffect(() => {
if (fontSizeMode === 'static') {
setFontSize(staticFontSize);
setHasSized(true);
}
}, [fontSizeMode, staticFontSize]);
@@ -158,8 +153,6 @@ export function FormulaResult({
? theme.errorText
: theme.pageText;
const showContent = hasSized || fontSizeMode === 'static';
return (
<View style={{ flex: 1 }}>
{loading && <LoadingIndicator />}
@@ -182,13 +175,9 @@ export function FormulaResult({
color,
}}
>
{!showContent ? (
<LoadingIndicator />
) : (
<span aria-hidden="true">
<PrivacyFilter>{displayValue}</PrivacyFilter>
</span>
)}
<span aria-hidden="true">
<PrivacyFilter>{displayValue}</PrivacyFilter>
</span>
</View>
)}
</View>

View File

@@ -38,7 +38,6 @@ export function SummaryNumber({
}: SummaryNumberProps) {
const { t } = useTranslation();
const [fontSize, setFontSize] = useState<number>(initialFontSize);
const [hasSized, setHasSized] = useState(false);
const refDiv = useRef<HTMLDivElement>(null);
const format = useFormat();
const isNumericValue = Number.isFinite(value);
@@ -62,10 +61,7 @@ export function SummaryNumber({
height, // Ensure the text fits vertically by using the height as the limiting factor
);
if (calculatedFontSize > 0) {
setFontSize(calculatedFontSize);
setHasSized(true);
}
setFontSize(calculatedFontSize);
if (calculatedFontSize !== initialFontSize && fontSizeChanged) {
fontSizeChanged(calculatedFontSize);
@@ -111,13 +107,9 @@ export function SummaryNumber({
: theme.reportsNumberPositive,
}}
>
{!hasSized ? (
<LoadingIndicator />
) : (
<FinancialText aria-hidden="true">
<PrivacyFilter>{displayAmount}</PrivacyFilter>
</FinancialText>
)}
<FinancialText aria-hidden="true">
<PrivacyFilter>{displayAmount}</PrivacyFilter>
</FinancialText>
</View>
)}
</>

View File

@@ -246,9 +246,7 @@ export function ThemeInstaller({
return null;
}
const catalogItems = [...(catalog ?? [])].sort((a, b) =>
a.name.localeCompare(b.name),
);
const catalogItems = catalog ?? [];
const itemsPerRow = getItemsPerRow(width);
const rows: CatalogTheme[][] = [];
for (let i = 0; i < catalogItems.length; i += itemsPerRow) {

View File

@@ -1 +0,0 @@
import 'loot-core/typings/window';

View File

@@ -19,7 +19,13 @@
{ "path": "../loot-core" },
{ "path": "../component-library" }
],
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.js"],
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.js",
// TODO: remove loot-core dependency
"../../packages/loot-core/typings/window.ts"
],
"exclude": [
"node_modules",
"build",

View File

@@ -49,7 +49,6 @@
"./shared/*": "./src/shared/*.ts",
"./types/models": "./src/types/models/index.ts",
"./types/*": "./src/types/*.ts",
"./typings/*": "./typings/*.ts",
"./lib-dist/electron/bundle.desktop.js": "./lib-dist/electron/bundle.desktop.js"
},
"scripts": {

View File

@@ -72,15 +72,6 @@ describe('utility functions', () => {
expect(looselyParseAmount('(1 500.99)')).toBe(-1500.99);
});
test('looseParseAmount handles trailing whitespace', () => {
expect(looselyParseAmount('1055 ')).toBe(1055);
expect(looselyParseAmount('$1,055 ')).toBe(1055);
expect(looselyParseAmount('$1,055.00 ')).toBe(1055);
expect(looselyParseAmount(' $1,055 ')).toBe(1055);
expect(looselyParseAmount('3.45 ')).toBe(3.45);
expect(looselyParseAmount(' 3.45 ')).toBe(3.45);
});
test('number formatting works with comma-dot format', () => {
setNumberFormat({ format: 'comma-dot', hideFraction: false });
let formatter = getNumberFormat().formatter;

View File

@@ -550,8 +550,6 @@ export function looselyParseAmount(amount: string) {
return v.replace(/[^0-9-]/g, '');
}
amount = amount.trim();
if (amount.startsWith('(') && amount.endsWith(')')) {
// Remove Unicode minus inside parentheses before converting to ASCII minus
amount = amount.replace(/\u2212/g, '');

View File

@@ -38,7 +38,7 @@
"date-fns": "^4.1.0",
"debug": "^4.4.3",
"express": "^5.2.1",
"express-rate-limit": "^8.3.0",
"express-rate-limit": "^8.2.1",
"express-winston": "^4.2.0",
"ipaddr.js": "^2.3.0",
"jws": "^4.0.1",

View File

@@ -28,9 +28,7 @@ export async function run(direction: 'up' | 'down' = 'up'): Promise<void> {
> = {};
for (const f of files
.filter(
f => (f.endsWith('.js') || f.endsWith('.ts')) && !f.endsWith('.d.ts'),
)
.filter(f => f.endsWith('.js') || f.endsWith('.ts'))
.sort()) {
migrationsModules[f] = await import(
pathToFileURL(path.join(migrationsDir, f)).href

View File

@@ -1,6 +0,0 @@
---
category: Bugfixes
authors: [matt-fidd]
---
Stop font size fluctuations showing in summary cards

View File

@@ -1,6 +0,0 @@
---
category: Maintenance
authors: [dependabot[bot]]
---
Bump `express-rate-limit` dependency version from 8.2.1 to 8.3.0 for improvements.

View File

@@ -1,6 +0,0 @@
---
category: Maintenance
authors: [MikesGlitch]
---
Adding more retries to the Docker test in the pipeline

View File

@@ -1,6 +0,0 @@
---
category: Maintenance
authors: [MatissJanis]
---
Refactor TypeScript typings by moving window import to globals.ts for cleaner configuration.

View File

@@ -1,6 +0,0 @@
---
category: Enhancements
authors: [MatissJanis]
---
Sort theme catalog items alphabetically by name for improved user interface organization.

View File

@@ -1,6 +0,0 @@
---
category: Bugfix
authors: [MikesGlitch]
---
Fix migrations retrieval when running the docker images

View File

@@ -1,6 +0,0 @@
---
category: Bugfixes
authors: [mibragimov]
---
Fix CSV import incorrectly parsing transaction amounts that contain trailing whitespace (e.g. amounts from Excel-saved CSV files).

View File

@@ -118,7 +118,7 @@ __metadata:
date-fns: "npm:^4.1.0"
debug: "npm:^4.4.3"
express: "npm:^5.2.1"
express-rate-limit: "npm:^8.3.0"
express-rate-limit: "npm:^8.2.1"
express-winston: "npm:^4.2.0"
http-proxy-middleware: "npm:^3.0.5"
ipaddr.js: "npm:^2.3.0"
@@ -8852,6 +8852,13 @@ __metadata:
languageName: node
linkType: hard
"@trysound/sax@npm:0.2.0":
version: 0.2.0
resolution: "@trysound/sax@npm:0.2.0"
checksum: 10/7379713eca480ac0d9b6c7b063e06b00a7eac57092354556c81027066eb65b61ea141a69d0cc2e15d32e05b2834d4c9c2184793a5e36bbf5daf05ee5676af18c
languageName: node
linkType: hard
"@tsconfig/node10@npm:^1.0.7":
version: 1.0.11
resolution: "@tsconfig/node10@npm:1.0.11"
@@ -14692,14 +14699,14 @@ __metadata:
linkType: hard
"dompurify@npm:^3.2.5":
version: 3.3.2
resolution: "dompurify@npm:3.3.2"
version: 3.3.0
resolution: "dompurify@npm:3.3.0"
dependencies:
"@types/trusted-types": "npm:^2.0.7"
dependenciesMeta:
"@types/trusted-types":
optional: true
checksum: 10/3ca02559677ce6d9583a500f21ffbb6b9e88f1af99f69fa0d0d9442cddbac98810588c869f8b435addb5115492d6e49870024bca322169b941bafedb99c7f281
checksum: 10/d8782b10a0454344476936c91038d06c9450b3e3ada2ceb8f722525e6b54e64d847939b9f35bf385facd4139f0a2eaf7f5553efce351f8e9295620570875f002
languageName: node
linkType: hard
@@ -15910,14 +15917,14 @@ __metadata:
languageName: node
linkType: hard
"express-rate-limit@npm:^8.3.0":
version: 8.3.0
resolution: "express-rate-limit@npm:8.3.0"
"express-rate-limit@npm:^8.2.1":
version: 8.2.1
resolution: "express-rate-limit@npm:8.2.1"
dependencies:
ip-address: "npm:10.1.0"
ip-address: "npm:10.0.1"
peerDependencies:
express: ">= 4.11"
checksum: 10/e896a66fecc10639e65873186fdfb71f19d6af650220eb7ea5450725215c3eed8dc6ddcfa1e68a9db8c9facc3326fbc281512ad3ccd8f107f42a2466ce12c18c
checksum: 10/7cbf70df2e88e590e463d2d8f93380775b2ea181d97f2c50c2ff9f2c666c247f83109a852b21d9c99ccc5762119101f281f54a27252a2f1a0a918be6d71f955b
languageName: node
linkType: hard
@@ -18003,9 +18010,9 @@ __metadata:
linkType: hard
"immutable@npm:^5.0.2":
version: 5.1.5
resolution: "immutable@npm:5.1.5"
checksum: 10/7aec2740239772ec8e92e793c991bd809203a97694f4ff3a18e50e28f9a6b02393ad033d87b458037bdf8c0ea37d4446d640e825f6171df3405cf6cf300ce028
version: 5.1.3
resolution: "immutable@npm:5.1.3"
checksum: 10/6d29b29036143e7ea1e7f7be581c71bca873ea77c175d33c6c839bf4017265a58c41ec269e3ffcd7b483797fc7fa9c928b4ed3d6edfeeb1b5711d84f60d04090
languageName: node
linkType: hard
@@ -18138,14 +18145,7 @@ __metadata:
languageName: node
linkType: hard
"ip-address@npm:10.1.0":
version: 10.1.0
resolution: "ip-address@npm:10.1.0"
checksum: 10/a6979629d1ad9c1fb424bc25182203fad739b40225aebc55ec6243bbff5035faf7b9ed6efab3a097de6e713acbbfde944baacfa73e11852bb43989c45a68d79e
languageName: node
linkType: hard
"ip-address@npm:^10.0.1":
"ip-address@npm:10.0.1, ip-address@npm:^10.0.1":
version: 10.0.1
resolution: "ip-address@npm:10.0.1"
checksum: 10/09731acda32cd8e14c46830c137e7e5940f47b36d63ffb87c737331270287d631cf25aa95570907a67d3f919fdb25f4470c404eda21e62f22e0a55927f4dd0fb
@@ -25613,13 +25613,6 @@ __metadata:
languageName: node
linkType: hard
"sax@npm:^1.5.0":
version: 1.5.0
resolution: "sax@npm:1.5.0"
checksum: 10/9012ff37dda7a7ac5da45db2143b04036103e8bef8d586c3023afd5df6caf0ebd7f38017eee344ad2e2247eded7d38e9c42cf291d8dd91781352900ac0fd2d9f
languageName: node
linkType: hard
"saxes@npm:^6.0.0":
version: 6.0.0
resolution: "saxes@npm:6.0.0"
@@ -26945,19 +26938,19 @@ __metadata:
linkType: hard
"svgo@npm:^3.0.2, svgo@npm:^3.2.0":
version: 3.3.3
resolution: "svgo@npm:3.3.3"
version: 3.3.2
resolution: "svgo@npm:3.3.2"
dependencies:
"@trysound/sax": "npm:0.2.0"
commander: "npm:^7.2.0"
css-select: "npm:^5.1.0"
css-tree: "npm:^2.3.1"
css-what: "npm:^6.1.0"
csso: "npm:^5.0.5"
picocolors: "npm:^1.0.0"
sax: "npm:^1.5.0"
bin:
svgo: ./bin/svgo
checksum: 10/f3c1b4d05d1704483e53515d5995af5f06a2718df85e3a8320f57bb256b8dc926b84c87a1a9b98e9d3ca1224314cc0676a803bdd03163508292f2d45c7077096
checksum: 10/82fdea9b938884d808506104228e4d3af0050d643d5b46ff7abc903ff47a91bbf6561373394868aaf07a28f006c4057b8fbf14bbd666298abdd7cc590d4f7700
languageName: node
linkType: hard