Compare commits
260 Commits
enhance/re
...
fix/compil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60b64e10ff | ||
|
|
6365a8f4bb | ||
|
|
14426b64fd | ||
|
|
65790d4b9c | ||
|
|
9af4ba4d07 | ||
|
|
28caf8eaf9 | ||
|
|
81160256bc | ||
|
|
ca5378c0e8 | ||
|
|
08b5b7fdc7 | ||
|
|
67c0b6911b | ||
|
|
4e9e153989 | ||
|
|
b0321ee265 | ||
|
|
753a105b3d | ||
|
|
5a888d44b9 | ||
|
|
7a4799de94 | ||
|
|
4ad369cd8f | ||
|
|
2c9a66cec6 | ||
|
|
6e96b81799 | ||
|
|
f89d4fd13d | ||
|
|
cc0812113a | ||
|
|
59724d445f | ||
|
|
6b99497d5d | ||
|
|
5f5457b226 | ||
|
|
4bdcb27573 | ||
|
|
8ae070ab12 | ||
|
|
0ca5bec094 | ||
|
|
988bc21818 | ||
|
|
f4419b96de | ||
|
|
e30a38ced8 | ||
|
|
98b91cfb8d | ||
|
|
942d3ea4d5 | ||
|
|
3c9b70df79 | ||
|
|
5c18b53888 | ||
|
|
413398531c | ||
|
|
e4c3d4e12a | ||
|
|
91b838c539 | ||
|
|
9eb0e04c6a | ||
|
|
14bf3d611c | ||
|
|
34b6599da3 | ||
|
|
bc1cd9023c | ||
|
|
5ae9176f5e | ||
|
|
2ed908aff4 | ||
|
|
3318dd56e9 | ||
|
|
00ab11cc40 | ||
|
|
25c83eb64d | ||
|
|
7a420b79f2 | ||
|
|
d2cfedf5e4 | ||
|
|
00a4cfcabf | ||
|
|
a18a05f55a | ||
|
|
b399f290a6 | ||
|
|
7c07295448 | ||
|
|
510dd31de6 | ||
|
|
8e5a88bc55 | ||
|
|
bbf91ccbca | ||
|
|
58bc14e1b3 | ||
|
|
de2966a06c | ||
|
|
90b859fd74 | ||
|
|
fafcee071d | ||
|
|
ed40901534 | ||
|
|
338093836b | ||
|
|
4df05aa37c | ||
|
|
5459b8baca | ||
|
|
073d91a7b7 | ||
|
|
58a638cee2 | ||
|
|
23f1bae7db | ||
|
|
57240284a3 | ||
|
|
6c6d8931bb | ||
|
|
cae8fa4e6f | ||
|
|
48ae371ecc | ||
|
|
e8d93fb797 | ||
|
|
6790f99de2 | ||
|
|
68f0b05aed | ||
|
|
c954d3924e | ||
|
|
adf4bd2d0f | ||
|
|
102c6eaff6 | ||
|
|
21105fc25b | ||
|
|
c69142f58e | ||
|
|
fe32bf14c6 | ||
|
|
92e43bc3b5 | ||
|
|
165be3d0df | ||
|
|
3dd22994b7 | ||
|
|
96bfc69332 | ||
|
|
284fc13161 | ||
|
|
30102b1474 | ||
|
|
3a8eb96d76 | ||
|
|
91a8bc3ef1 | ||
|
|
dc2ab4843f | ||
|
|
89e5676cfb | ||
|
|
645342d47d | ||
|
|
116c695964 | ||
|
|
a5d18929c8 | ||
|
|
989d332e1b | ||
|
|
169d08e721 | ||
|
|
a74da11904 | ||
|
|
cccd66713d | ||
|
|
1ce53b2762 | ||
|
|
d75f984186 | ||
|
|
692ade7254 | ||
|
|
da0ac0b144 | ||
|
|
be20f65b6e | ||
|
|
1067e32028 | ||
|
|
dcb1c69e67 | ||
|
|
f084e28086 | ||
|
|
f54e459e03 | ||
|
|
ccdde60bfe | ||
|
|
712d315229 | ||
|
|
31c6362307 | ||
|
|
d1519993d6 | ||
|
|
ebde78434a | ||
|
|
8fcaff8e3a | ||
|
|
13bc99738f | ||
|
|
959824d317 | ||
|
|
2abc144b03 | ||
|
|
71250f5fb7 | ||
|
|
c5f050f6f8 | ||
|
|
0d46e221f9 | ||
|
|
6bf2f581a3 | ||
|
|
3c34603111 | ||
|
|
3e488ae8f7 | ||
|
|
bacf3091b6 | ||
|
|
ac77c0f360 | ||
|
|
e21256e7a2 | ||
|
|
22237d11ca | ||
|
|
63604c1161 | ||
|
|
74b95ca83e | ||
|
|
6a9028464b | ||
|
|
186d417c6e | ||
|
|
c898116412 | ||
|
|
85bd6bfb81 | ||
|
|
72616376e2 | ||
|
|
136ad055f7 | ||
|
|
c621f68e0a | ||
|
|
4f611ca458 | ||
|
|
d98e8375a8 | ||
|
|
4f50c1a889 | ||
|
|
0fa582b3d3 | ||
|
|
80cd2cf347 | ||
|
|
f328332ab2 | ||
|
|
7465bdb54d | ||
|
|
1ad3406e84 | ||
|
|
48166952ce | ||
|
|
994b959050 | ||
|
|
7f4f5005a5 | ||
|
|
1e05b169c8 | ||
|
|
c54a5b3405 | ||
|
|
5c11a0a51a | ||
|
|
8f69669cc6 | ||
|
|
9ebdba27fd | ||
|
|
8df3d23e03 | ||
|
|
5d238c238d | ||
|
|
e69235a35b | ||
|
|
611f7b046a | ||
|
|
ee8f7453ba | ||
|
|
380fae1ccd | ||
|
|
8d84f16604 | ||
|
|
239a087542 | ||
|
|
895d69f875 | ||
|
|
1cb5e97fab | ||
|
|
13bd08d243 | ||
|
|
d946852ee9 | ||
|
|
4cdff76547 | ||
|
|
1a1975b5ab | ||
|
|
6e2154d401 | ||
|
|
45bfd23daa | ||
|
|
c44b32805f | ||
|
|
45530638fe | ||
|
|
a15ff85c20 | ||
|
|
1861060bda | ||
|
|
9281acb819 | ||
|
|
dc86441809 | ||
|
|
78bcac95ed | ||
|
|
45610bae81 | ||
|
|
f5a6700b21 | ||
|
|
8cea059834 | ||
|
|
31b31f2edb | ||
|
|
4b56e0cb54 | ||
|
|
f8dd4897e7 | ||
|
|
526d7a9baa | ||
|
|
af8f26f200 | ||
|
|
548f36e8d6 | ||
|
|
9aed1f400c | ||
|
|
59bcee9369 | ||
|
|
ff7529f6e5 | ||
|
|
77e99af297 | ||
|
|
82a3c97222 | ||
|
|
cafcc823cb | ||
|
|
582e27dbdb | ||
|
|
d747563915 | ||
|
|
84dec137bb | ||
|
|
76de8bf67f | ||
|
|
5db7026435 | ||
|
|
e4a993ad67 | ||
|
|
38ab63638a | ||
|
|
4eea349966 | ||
|
|
f66baeabd3 | ||
|
|
19180138bb | ||
|
|
7705a2df08 | ||
|
|
202af094af | ||
|
|
333e7ff7bc | ||
|
|
eb11e14e94 | ||
|
|
dbcfb63857 | ||
|
|
64f4d200dd | ||
|
|
4be7e03570 | ||
|
|
ca969cc61b | ||
|
|
682d439c34 | ||
|
|
37d91a90f7 | ||
|
|
3a09d91399 | ||
|
|
405c8b986f | ||
|
|
bd9f0aec89 | ||
|
|
a4f0d5bca8 | ||
|
|
0085c3b58a | ||
|
|
cfb0f51e36 | ||
|
|
2d95fe6d03 | ||
|
|
80bb4ab299 | ||
|
|
2f71e007d6 | ||
|
|
7b71374e79 | ||
|
|
c1d70722b8 | ||
|
|
2cd79960a9 | ||
|
|
9a6afda6de | ||
|
|
979fa43c4a | ||
|
|
a20805bfae | ||
|
|
485830c859 | ||
|
|
4aea6f4898 | ||
|
|
1f828b6562 | ||
|
|
ad9a84ddf1 | ||
|
|
d9a171b249 | ||
|
|
e5c84d4ae0 | ||
|
|
94a76a008d | ||
|
|
432c2b6165 | ||
|
|
46eb2a7c38 | ||
|
|
3214d5dd53 | ||
|
|
66d8f1a631 | ||
|
|
e3aa63d1fa | ||
|
|
9478707ebb | ||
|
|
9952412e1d | ||
|
|
8231bbbf5a | ||
|
|
eadd88ce31 | ||
|
|
6c61cf6a8d | ||
|
|
0fb9c252ca | ||
|
|
d73ead135e | ||
|
|
2c87c44168 | ||
|
|
15beba2ca3 | ||
|
|
667cc24fac | ||
|
|
4cc542a658 | ||
|
|
093d799ba0 | ||
|
|
68d10f6b29 | ||
|
|
252f04e02c | ||
|
|
13cb85835b | ||
|
|
39cf04c74d | ||
|
|
562b5e2afd | ||
|
|
4b4e32d0e2 | ||
|
|
d821f1cebc | ||
|
|
53e3694a38 | ||
|
|
8647452ccc | ||
|
|
5a40b017f0 | ||
|
|
8ccc1af77e | ||
|
|
07904c209e | ||
|
|
234f008dcf | ||
|
|
d130b427b3 | ||
|
|
39cd71aa48 |
12
.cursor/rules/commands.mdc
Normal 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
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
Before pushing code changes or opening a pull request, follow these steps:
|
||||
|
||||
1. Check if your branch already has a changelog file in the "upcoming-release-notes" folder.
|
||||
2. If there is no changelog file for your branch:
|
||||
a. Find the number of the most recent (highest-numbered) open issue or pull request on GitHub.
|
||||
b. Increment that number by 1. Use this as the filename for your new changelog file.
|
||||
c. Create a new markdown file in the "upcoming-release-notes" folder with the following format:
|
||||
|
||||
```
|
||||
---
|
||||
category: Features OR Maintenance OR Enhancements OR Bugfix
|
||||
authors: [$GithubUsername]
|
||||
---
|
||||
|
||||
$Description
|
||||
```
|
||||
|
||||
3. Commit the new changelog file.
|
||||
4. Proceed with your push or pull request.
|
||||
@@ -13,6 +13,7 @@ Code Style and Structure
|
||||
- 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
|
||||
|
||||
@@ -30,3 +31,7 @@ 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
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -2,14 +2,13 @@ name: Bug Report
|
||||
description: File a bug report also known as an issue or problem.
|
||||
title: '[Bug]: '
|
||||
labels: ['needs triage', 'bug']
|
||||
type: Bug
|
||||
body:
|
||||
- type: markdown
|
||||
id: intro-md
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report! Please ensure you provide as much information as possible to better assist in confirming and identifying a fix for the bug.
|
||||
- type: markdown
|
||||
id: intro-md
|
||||
attributes:
|
||||
value: |
|
||||
**IMPORTANT:** we use GitHub Issues only for BUG REPORTS and FEATURE REQUESTS. If you are looking for help/support - please reach out to the [community on Discord](https://discord.gg/pRYNYr4W5A). All non-bug and non-feature-request issues will be closed.
|
||||
@@ -23,8 +22,6 @@ body:
|
||||
options:
|
||||
- label: 'I have searched and found no existing issue'
|
||||
required: true
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
@@ -43,7 +40,6 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
id: env-info
|
||||
attributes:
|
||||
value: '## Environment Details'
|
||||
- type: dropdown
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@@ -2,9 +2,9 @@ name: Feature request
|
||||
description: Request a missing feature
|
||||
title: '[Feature] '
|
||||
labels: ['feature']
|
||||
type: Feature
|
||||
body:
|
||||
- type: markdown
|
||||
id: intro-md
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this feature request! Please ensure you provide as much information as possible so we can better understand what you’re proposing so we can come up with the best solution for everyone.
|
||||
@@ -16,8 +16,6 @@ body:
|
||||
options:
|
||||
- label: 'I have searched and found no existing issue'
|
||||
required: true
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: '💻'
|
||||
|
||||
75
.github/actions/ai-generated-release-notes/check-first-comment.js
vendored
Executable file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import fs from 'fs';
|
||||
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
const issueNumber = process.env.GITHUB_EVENT_ISSUE_NUMBER;
|
||||
const commentId = process.env.GITHUB_EVENT_COMMENT_ID;
|
||||
|
||||
if (!token || !repo || !issueNumber || !commentId) {
|
||||
console.log('Missing required environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [owner, repoName] = repo.split('/');
|
||||
const octokit = new Octokit({ auth: token });
|
||||
|
||||
function setOutput(name, value) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
|
||||
}
|
||||
|
||||
async function checkFirstComment() {
|
||||
try {
|
||||
console.log('Fetching comments with Octokit...');
|
||||
|
||||
// Get all comments with automatic pagination
|
||||
const comments = await octokit.paginate(octokit.rest.issues.listComments, {
|
||||
owner,
|
||||
repo: repoName,
|
||||
issue_number: issueNumber,
|
||||
});
|
||||
|
||||
console.log(`Total comments found: ${comments.length}`);
|
||||
|
||||
// Filter for CodeRabbit summary comments (containing the specific marker)
|
||||
const coderabbitSummaryComments = comments.filter(comment => {
|
||||
const isCodeRabbit = comment.user.login === 'coderabbitai[bot]';
|
||||
const hasSummaryMarker = comment.body.includes(
|
||||
'<!-- This is an auto-generated comment: summarize by coderabbit.ai -->',
|
||||
);
|
||||
|
||||
if (isCodeRabbit) {
|
||||
console.log(
|
||||
`CodeRabbit comment found (ID: ${comment.id}), has summary marker: ${hasSummaryMarker}`,
|
||||
);
|
||||
}
|
||||
|
||||
return isCodeRabbit && hasSummaryMarker;
|
||||
});
|
||||
|
||||
const isFirstSummaryComment =
|
||||
coderabbitSummaryComments.length === 1 &&
|
||||
coderabbitSummaryComments[0].id == commentId;
|
||||
|
||||
console.log(
|
||||
`CodeRabbit summary comments found: ${coderabbitSummaryComments.length}`,
|
||||
);
|
||||
console.log(`Current comment ID: ${commentId}`);
|
||||
console.log(`Is first summary comment: ${isFirstSummaryComment}`);
|
||||
setOutput('result', isFirstSummaryComment);
|
||||
} catch (error) {
|
||||
console.log('Error checking CodeRabbit comment:', error.message);
|
||||
console.log('Stack:', error.stack);
|
||||
setOutput('result', 'false');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
checkFirstComment().catch(error => {
|
||||
console.log('Unhandled error:', error.message);
|
||||
console.log('Stack:', error.stack);
|
||||
setOutput('result', 'false');
|
||||
process.exit(1);
|
||||
});
|
||||
76
.github/actions/ai-generated-release-notes/check-release-notes-exists.js
vendored
Executable file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import fs from 'fs';
|
||||
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
const issueNumber = process.env.GITHUB_EVENT_ISSUE_NUMBER;
|
||||
const prDetailsJson = process.env.PR_DETAILS;
|
||||
|
||||
if (!token || !repo || !issueNumber || !prDetailsJson) {
|
||||
console.log('Missing required environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [owner, repoName] = repo.split('/');
|
||||
const octokit = new Octokit({ auth: token });
|
||||
|
||||
function setOutput(name, value) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
|
||||
}
|
||||
|
||||
async function checkReleaseNotesExists() {
|
||||
try {
|
||||
const prDetails = JSON.parse(prDetailsJson);
|
||||
if (!prDetails) {
|
||||
console.log('No PR details available, skipping file check');
|
||||
setOutput('result', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = `upcoming-release-notes/${prDetails.number}.md`;
|
||||
|
||||
// Get PR info to get head SHA
|
||||
const { data: pr } = await octokit.rest.pulls.get({
|
||||
owner,
|
||||
repo: repoName,
|
||||
pull_number: issueNumber,
|
||||
});
|
||||
|
||||
const prHeadSha = pr.head.sha;
|
||||
console.log(
|
||||
`Checking for file on PR branch: ${pr.head.ref} (${prHeadSha})`,
|
||||
);
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await octokit.rest.repos.getContent({
|
||||
owner,
|
||||
repo: repoName,
|
||||
path: fileName,
|
||||
ref: prHeadSha,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Release notes file already exists on PR branch: ${fileName}`,
|
||||
);
|
||||
setOutput('result', 'true');
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
console.log(
|
||||
`No existing release notes file found on PR branch: ${fileName}`,
|
||||
);
|
||||
setOutput('result', 'false');
|
||||
} else {
|
||||
console.log('Error checking file existence:', error.message);
|
||||
setOutput('result', 'false');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error in file existence check:', error.message);
|
||||
setOutput('result', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
checkReleaseNotesExists();
|
||||
77
.github/actions/ai-generated-release-notes/comment-on-pr.js
vendored
Executable file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Octokit } from '@octokit/rest';
|
||||
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
const issueNumber = process.env.GITHUB_EVENT_ISSUE_NUMBER;
|
||||
const summaryDataJson = process.env.SUMMARY_DATA;
|
||||
const category = process.env.CATEGORY;
|
||||
|
||||
if (!token || !repo || !issueNumber || !summaryDataJson || !category) {
|
||||
console.log('Missing required environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [owner, repoName] = repo.split('/');
|
||||
const octokit = new Octokit({ auth: token });
|
||||
|
||||
async function commentOnPR() {
|
||||
try {
|
||||
const summaryData = JSON.parse(summaryDataJson);
|
||||
|
||||
if (!summaryData) {
|
||||
console.log('No summary data available, skipping comment');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!category || category === 'null') {
|
||||
console.log('No valid category available, skipping comment');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean category for display
|
||||
const cleanCategory =
|
||||
typeof category === 'string'
|
||||
? category.replace(/^["']|["']$/g, '')
|
||||
: category;
|
||||
|
||||
// Get PR info for the file URL
|
||||
const { data: pr } = await octokit.rest.pulls.get({
|
||||
owner,
|
||||
repo: repoName,
|
||||
pull_number: issueNumber,
|
||||
});
|
||||
|
||||
const prBranch = pr.head.ref;
|
||||
const headOwner = pr.head.repo.owner.login;
|
||||
const headRepo = pr.head.repo.name;
|
||||
const fileUrl = `https://github.com/${headOwner}/${headRepo}/blob/${prBranch}/upcoming-release-notes/${summaryData.prNumber}.md`;
|
||||
|
||||
const commentBody = [
|
||||
'🤖 **Auto-generated Release Notes**',
|
||||
'',
|
||||
`Hey @${summaryData.author}! I've automatically created a release notes file based on CodeRabbit's analysis:`,
|
||||
'',
|
||||
`**Category:** ${cleanCategory}`,
|
||||
`**Summary:** ${summaryData.summary}`,
|
||||
`**File:** [upcoming-release-notes/${summaryData.prNumber}.md](${fileUrl})`,
|
||||
'',
|
||||
// 'The release notes file has been committed to the repository. You can edit it if needed before merging.',
|
||||
"If you're happy with this release note, you can add it to your pull request. If not, you'll need to add your own before a maintainer can review your change.",
|
||||
].join('\n');
|
||||
|
||||
await octokit.rest.issues.createComment({
|
||||
owner,
|
||||
repo: repoName,
|
||||
issue_number: issueNumber,
|
||||
body: commentBody,
|
||||
});
|
||||
|
||||
console.log('✅ Successfully commented on PR');
|
||||
} catch (error) {
|
||||
console.log('Error commenting on PR:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
commentOnPR();
|
||||
96
.github/actions/ai-generated-release-notes/create-release-notes-file.js
vendored
Executable file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Octokit } from '@octokit/rest';
|
||||
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
const issueNumber = process.env.GITHUB_EVENT_ISSUE_NUMBER;
|
||||
const summaryDataJson = process.env.SUMMARY_DATA;
|
||||
const category = process.env.CATEGORY;
|
||||
|
||||
if (!token || !repo || !issueNumber || !summaryDataJson || !category) {
|
||||
console.log('Missing required environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [owner, repoName] = repo.split('/');
|
||||
const octokit = new Octokit({ auth: token });
|
||||
|
||||
async function createReleaseNotesFile() {
|
||||
try {
|
||||
const summaryData = JSON.parse(summaryDataJson);
|
||||
|
||||
console.log('Debug - Category value:', category);
|
||||
console.log('Debug - Category type:', typeof category);
|
||||
console.log('Debug - Category JSON stringified:', JSON.stringify(category));
|
||||
|
||||
if (!summaryData) {
|
||||
console.log('No summary data available, cannot create file');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!category || category === 'null') {
|
||||
console.log('No valid category available, cannot create file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create file content - ensure category is not quoted
|
||||
const cleanCategory =
|
||||
typeof category === 'string'
|
||||
? category.replace(/^["']|["']$/g, '')
|
||||
: category;
|
||||
console.log('Debug - Clean category:', cleanCategory);
|
||||
|
||||
const fileContent = `---
|
||||
category: ${cleanCategory}
|
||||
authors: [${summaryData.author}]
|
||||
---
|
||||
|
||||
${summaryData.summary}`;
|
||||
|
||||
const fileName = `upcoming-release-notes/${summaryData.prNumber}.md`;
|
||||
|
||||
console.log(`Creating release notes file: ${fileName}`);
|
||||
console.log('File content:');
|
||||
console.log(fileContent);
|
||||
|
||||
// Get PR info
|
||||
const { data: pr } = await octokit.rest.pulls.get({
|
||||
owner,
|
||||
repo: repoName,
|
||||
pull_number: issueNumber,
|
||||
});
|
||||
|
||||
const prBranch = pr.head.ref;
|
||||
const headOwner = pr.head.repo.owner.login;
|
||||
const headRepo = pr.head.repo.name;
|
||||
|
||||
console.log(
|
||||
`Committing to PR branch: ${headOwner}/${headRepo}:${prBranch}`,
|
||||
);
|
||||
|
||||
// Create the file via GitHub API on the PR branch
|
||||
await octokit.rest.repos.createOrUpdateFileContents({
|
||||
owner: headOwner,
|
||||
repo: headRepo,
|
||||
path: fileName,
|
||||
message: `Add release notes for PR #${summaryData.prNumber}`,
|
||||
content: Buffer.from(`${fileContent}\n\n`).toString('base64'),
|
||||
branch: prBranch,
|
||||
committer: {
|
||||
name: 'github-actions[bot]',
|
||||
email: 'github-actions[bot]@users.noreply.github.com',
|
||||
},
|
||||
author: {
|
||||
name: 'github-actions[bot]',
|
||||
email: 'github-actions[bot]@users.noreply.github.com',
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Successfully created release notes file: ${fileName}`);
|
||||
} catch (error) {
|
||||
console.log('Error creating release notes file:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
createReleaseNotesFile();
|
||||
118
.github/actions/ai-generated-release-notes/determine-category.js
vendored
Executable file
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
|
||||
const commentBody = process.env.GITHUB_EVENT_COMMENT_BODY;
|
||||
const prDetailsJson = process.env.PR_DETAILS;
|
||||
const summaryDataJson = process.env.SUMMARY_DATA;
|
||||
const openaiApiKey = process.env.OPENAI_API_KEY;
|
||||
|
||||
if (!commentBody || !prDetailsJson || !summaryDataJson || !openaiApiKey) {
|
||||
console.log('Missing required environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function setOutput(name, value) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
const prDetails = JSON.parse(prDetailsJson);
|
||||
const summaryData = JSON.parse(summaryDataJson);
|
||||
|
||||
if (!summaryData || !prDetails) {
|
||||
console.log('Missing data for categorization');
|
||||
setOutput('result', 'null');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const data = JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'You are categorizing pull requests for release notes. You must respond with exactly one of these categories: "Features", "Enhancements", "Bugfix", or "Maintenance". No other text or explanation.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `PR Title: ${prDetails.title}\n\nGenerated Summary: ${summaryData.summary}\n\nCodeRabbit Analysis:\n${commentBody}\n\nCategories:\n- Features: New functionality or capabilities\n- Bugfix: Fixes for broken or incorrect behavior\n- Enhancements: Improvements to existing functionality\n- Maintenance: Code cleanup, refactoring, dependencies, etc.\n\nWhat category does this PR belong to?`,
|
||||
},
|
||||
],
|
||||
max_tokens: 10,
|
||||
temperature: 0.1,
|
||||
});
|
||||
|
||||
const options = {
|
||||
hostname: 'api.openai.com',
|
||||
path: '/v1/chat/completions',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${openaiApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
const req = https.request(options, res => {
|
||||
let responseData = '';
|
||||
res.on('data', chunk => (responseData += chunk));
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) {
|
||||
console.log('OpenAI API error for categorization');
|
||||
setOutput('result', 'null');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = JSON.parse(responseData);
|
||||
console.log('OpenAI raw response:', JSON.stringify(response, null, 2));
|
||||
|
||||
const rawContent = response.choices[0].message.content.trim();
|
||||
console.log('Raw content from OpenAI:', rawContent);
|
||||
|
||||
let category;
|
||||
try {
|
||||
category = JSON.parse(rawContent);
|
||||
console.log('Parsed category:', category);
|
||||
} catch (parseError) {
|
||||
console.log(
|
||||
'JSON parse error, using raw content:',
|
||||
parseError.message,
|
||||
);
|
||||
category = rawContent;
|
||||
}
|
||||
|
||||
// Validate the category response
|
||||
const validCategories = [
|
||||
'Features',
|
||||
'Bugfix',
|
||||
'Enhancements',
|
||||
'Maintenance',
|
||||
];
|
||||
if (validCategories.includes(category)) {
|
||||
console.log('OpenAI categorized as:', category);
|
||||
setOutput('result', category);
|
||||
} else {
|
||||
console.log('Invalid category from OpenAI:', category);
|
||||
console.log('Valid categories are:', validCategories);
|
||||
setOutput('result', 'null');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error parsing OpenAI response:', error.message);
|
||||
setOutput('result', 'null');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', error => {
|
||||
console.log('Error in categorization:', error.message);
|
||||
setOutput('result', 'null');
|
||||
});
|
||||
|
||||
req.write(data);
|
||||
req.end();
|
||||
} catch (error) {
|
||||
console.log('Error in categorization:', error.message);
|
||||
setOutput('result', 'null');
|
||||
}
|
||||
97
.github/actions/ai-generated-release-notes/generate-summary.js
vendored
Executable file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
|
||||
const commentBody = process.env.GITHUB_EVENT_COMMENT_BODY;
|
||||
const prDetailsJson = process.env.PR_DETAILS;
|
||||
const openaiApiKey = process.env.OPENAI_API_KEY;
|
||||
|
||||
if (!commentBody || !prDetailsJson || !openaiApiKey) {
|
||||
console.log('Missing required environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function setOutput(name, value) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
const prDetails = JSON.parse(prDetailsJson);
|
||||
|
||||
if (!prDetails) {
|
||||
console.log('No PR details available, cannot generate summary');
|
||||
setOutput('result', 'null');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log('CodeRabbit comment body:', commentBody);
|
||||
|
||||
const data = JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'You are a technical writer helping to create concise release notes. Generate a maximum 15-word summary that describes what this PR does. Focus on the user-facing changes or bug fixes. Do not include "This PR" or similar phrases - just describe the change directly. Start with a base form verb (e.g., "Add" not "Adds", "Fix" not "Fixes", "Introduce" not "Introduces").',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `PR Title: ${prDetails.title}\n\nCodeRabbit Analysis:\n${commentBody}\n\nPlease provide a concise summary (max 15 words) of what this PR accomplishes.`,
|
||||
},
|
||||
],
|
||||
max_tokens: 50,
|
||||
temperature: 0.3,
|
||||
});
|
||||
|
||||
const options = {
|
||||
hostname: 'api.openai.com',
|
||||
path: '/v1/chat/completions',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${openaiApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
const req = https.request(options, res => {
|
||||
let responseData = '';
|
||||
res.on('data', chunk => (responseData += chunk));
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) {
|
||||
console.log(`OpenAI API error: ${res.statusCode} ${res.statusMessage}`);
|
||||
setOutput('result', 'null');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = JSON.parse(responseData);
|
||||
const summary = response.choices[0].message.content.trim();
|
||||
|
||||
console.log('Generated summary:', summary);
|
||||
|
||||
const result = {
|
||||
summary: summary,
|
||||
prNumber: prDetails.number,
|
||||
author: prDetails.author,
|
||||
};
|
||||
|
||||
setOutput('result', JSON.stringify(result));
|
||||
} catch (error) {
|
||||
console.log('Error parsing OpenAI response:', error.message);
|
||||
setOutput('result', 'null');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', error => {
|
||||
console.log('Error generating summary:', error.message);
|
||||
setOutput('result', 'null');
|
||||
});
|
||||
|
||||
req.write(data);
|
||||
req.end();
|
||||
} catch (error) {
|
||||
console.log('Error generating summary:', error.message);
|
||||
setOutput('result', 'null');
|
||||
}
|
||||
59
.github/actions/ai-generated-release-notes/pr-details.js
vendored
Executable file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import fs from 'fs';
|
||||
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
const issueNumber = process.env.GITHUB_EVENT_ISSUE_NUMBER;
|
||||
|
||||
if (!token || !repo || !issueNumber) {
|
||||
console.log('Missing required environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [owner, repoName] = repo.split('/');
|
||||
const octokit = new Octokit({ auth: token });
|
||||
|
||||
function setOutput(name, value) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
|
||||
}
|
||||
|
||||
async function getPRDetails() {
|
||||
try {
|
||||
console.log(
|
||||
`Fetching PR details for ${owner}/${repoName}#${issueNumber}...`,
|
||||
);
|
||||
|
||||
const { data: pr } = await octokit.rest.pulls.get({
|
||||
owner,
|
||||
repo: repoName,
|
||||
pull_number: issueNumber,
|
||||
});
|
||||
|
||||
console.log('PR details fetched successfully');
|
||||
console.log('- PR Number:', pr.number);
|
||||
console.log('- PR Author:', pr.user.login);
|
||||
console.log('- PR Title:', pr.title);
|
||||
|
||||
const result = {
|
||||
number: pr.number,
|
||||
author: pr.user.login,
|
||||
title: pr.title,
|
||||
};
|
||||
|
||||
setOutput('result', JSON.stringify(result));
|
||||
} catch (error) {
|
||||
console.log('Error getting PR details:', error.message);
|
||||
console.log('Stack:', error.stack);
|
||||
setOutput('result', 'null');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
getPRDetails().catch(error => {
|
||||
console.log('Unhandled error:', error.message);
|
||||
console.log('Stack:', error.stack);
|
||||
setOutput('result', 'null');
|
||||
process.exit(1);
|
||||
});
|
||||
53
.github/actions/bump-package-versions
vendored
@@ -1,53 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -gt 0 ]; then
|
||||
version="${1#v}"
|
||||
else
|
||||
version=""
|
||||
fi
|
||||
|
||||
files_to_bump=(
|
||||
packages/api/package.json
|
||||
packages/desktop-client/package.json
|
||||
packages/desktop-electron/package.json
|
||||
packages/sync-server/package.json
|
||||
)
|
||||
|
||||
for file in "${files_to_bump[@]}"; do
|
||||
if [ -z "$version" ]; then
|
||||
# version format: YY.MM.patch
|
||||
version="$(jq -r .version "$file" | perl -e '
|
||||
($y,$m,$p)=split(/\./,<>);
|
||||
($sec,$min,$hour,$day,$mon,$year)=localtime();
|
||||
$year -= 100; # Perl year starts at 1900
|
||||
$mon++; # Adjust 0-indexed month to 1-indexed
|
||||
if ($y == $year && $m == $mon) {
|
||||
if ($day <= 25) {
|
||||
# Patch release for the current month
|
||||
$p++;
|
||||
} else {
|
||||
# Use next month for a new release period
|
||||
$p = 0;
|
||||
$m++;
|
||||
$m > 12 && ($m=1, $y++);
|
||||
}
|
||||
} else {
|
||||
# Use the current date for a new release period
|
||||
$y = $year;
|
||||
$m = $mon;
|
||||
$p = 0;
|
||||
}
|
||||
print "$y.$m.$p\n";
|
||||
')"
|
||||
|
||||
if [ -z "$version" ]; then
|
||||
echo "Error: Failed to calculate new version" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Bumping $file to version $version"
|
||||
jq '.version = "'"$version"'"' "$file" > "$file.tmp"
|
||||
mv "$file.tmp" "$file"
|
||||
done
|
||||
94
.github/actions/get-next-package-version.js
vendored
@@ -1,94 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// This script is used in GitHub Actions to get the next version based on the current package.json version.
|
||||
// It supports three types of versioning: nightly, hotfix, and monthly.
|
||||
|
||||
const { parseArgs } = require('node:util');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const args = process.argv;
|
||||
|
||||
const options = {
|
||||
'package-json': {
|
||||
type: 'string',
|
||||
short: 'p',
|
||||
},
|
||||
type: {
|
||||
type: 'string', // nightly, hotfix, monthly
|
||||
short: 't',
|
||||
},
|
||||
};
|
||||
|
||||
const { values } = parseArgs({
|
||||
args,
|
||||
options,
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
if (!values['package-json']) {
|
||||
console.error(
|
||||
'Please specify the path to package.json using --package-json or -p option.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJsonPath = values['package-json'];
|
||||
|
||||
// Read and parse package.json
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const currentVersion = packageJson.version;
|
||||
|
||||
// Parse year and month from version (e.g. 25.5.1 -> year=2025, month=5)
|
||||
const versionParts = currentVersion.split('.');
|
||||
const versionYear = parseInt(versionParts[0]);
|
||||
const versionMonth = parseInt(versionParts[1]);
|
||||
const versionHotfix = parseInt(versionParts[2]);
|
||||
|
||||
// Create date and add 1 month
|
||||
const versionDate = new Date(2000 + versionYear, versionMonth - 1, 1); // month is 0-indexed
|
||||
const nextVersionMonthDate = new Date(
|
||||
versionDate.getFullYear(),
|
||||
versionDate.getMonth() + 1,
|
||||
1,
|
||||
);
|
||||
|
||||
// Format back to YY.M format
|
||||
const nextVersionYear = nextVersionMonthDate
|
||||
.getFullYear()
|
||||
.toString()
|
||||
.slice(-2);
|
||||
const nextVersionMonth = nextVersionMonthDate.getMonth() + 1; // Convert back to 1-indexed
|
||||
|
||||
// Get current date string
|
||||
const currentDate = new Date()
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
.replaceAll('-', '');
|
||||
|
||||
switch (values.type) {
|
||||
case 'nightly': {
|
||||
const newVersion = `${nextVersionYear}.${nextVersionMonth}.0-nightly.${currentDate}`;
|
||||
process.stdout.write(newVersion); // return the new version to stdout
|
||||
process.exit();
|
||||
}
|
||||
case 'hotfix': {
|
||||
const bugfixVersion = `${versionYear}.${versionMonth}.${versionHotfix + 1}`;
|
||||
process.stdout.write(bugfixVersion); // return the bugfix version to stdout
|
||||
process.exit();
|
||||
}
|
||||
case 'monthly': {
|
||||
const stableVersion = `${nextVersionYear}.${nextVersionMonth}.0`;
|
||||
process.stdout.write(stableVersion); // return the stable version to stdout
|
||||
process.exit();
|
||||
}
|
||||
default:
|
||||
console.error(
|
||||
'Invalid type specified. Use "nightly", "hotfix", or "monthly".',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
1
.github/actions/setup/action.yml
vendored
@@ -1,4 +1,5 @@
|
||||
name: Setup
|
||||
description: Setup the environment for the project
|
||||
|
||||
inputs:
|
||||
working-directory:
|
||||
|
||||
259
.github/scripts/count-points.mjs
vendored
@@ -1,5 +1,8 @@
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { minimatch } from 'minimatch';
|
||||
import pLimit from 'p-limit';
|
||||
|
||||
const limit = pLimit(50);
|
||||
|
||||
/** Repository-specific configuration for points calculation */
|
||||
const REPOSITORY_CONFIG = new Map([
|
||||
@@ -10,9 +13,10 @@ const REPOSITORY_CONFIG = new Map([
|
||||
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
|
||||
POINTS_PER_RELEASE_PR: 0,
|
||||
PR_REVIEW_POINT_TIERS: [
|
||||
{ minChanges: 1000, points: 6 },
|
||||
{ minChanges: 100, points: 4 },
|
||||
{ minChanges: 0, points: 2 },
|
||||
{ minChanges: 500, points: 8 },
|
||||
{ minChanges: 100, points: 6 },
|
||||
{ minChanges: 10, points: 2 },
|
||||
{ minChanges: 0, points: 1 },
|
||||
],
|
||||
EXCLUDED_FILES: [
|
||||
'yarn.lock',
|
||||
@@ -29,8 +33,8 @@ const REPOSITORY_CONFIG = new Map([
|
||||
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
|
||||
POINTS_PER_RELEASE_PR: 4,
|
||||
PR_REVIEW_POINT_TIERS: [
|
||||
{ minChanges: 1000, points: 6 },
|
||||
{ minChanges: 100, points: 4 },
|
||||
{ minChanges: 2000, points: 6 },
|
||||
{ minChanges: 200, points: 4 },
|
||||
{ minChanges: 0, points: 2 },
|
||||
],
|
||||
EXCLUDED_FILES: ['yarn.lock', '.yarn/**/*'],
|
||||
@@ -45,24 +49,25 @@ const REPOSITORY_CONFIG = new Map([
|
||||
function getLastMonthDates() {
|
||||
// Get data relating to the last month
|
||||
const now = new Date();
|
||||
// Always use UTC for calculations
|
||||
const firstDayOfLastMonth = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth() - 1,
|
||||
1,
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1, 0, 0, 0, 0),
|
||||
);
|
||||
const since = process.env.START_DATE
|
||||
? new Date(process.env.START_DATE)
|
||||
? new Date(Date.parse(process.env.START_DATE))
|
||||
: firstDayOfLastMonth;
|
||||
|
||||
// Calculate the end of the month for the since date
|
||||
// Calculate the end of the month for the since date in UTC
|
||||
const until = new Date(
|
||||
since.getFullYear(),
|
||||
since.getMonth() + 1,
|
||||
0,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
999,
|
||||
Date.UTC(
|
||||
since.getUTCFullYear(),
|
||||
since.getUTCMonth() + 1,
|
||||
0,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
999,
|
||||
),
|
||||
);
|
||||
|
||||
return { since, until };
|
||||
@@ -75,7 +80,9 @@ function getLastMonthDates() {
|
||||
* @returns {number} The total points earned for the repository
|
||||
*/
|
||||
async function countContributorPoints(repo) {
|
||||
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
|
||||
const octokit = new Octokit({
|
||||
auth: process.env.GITHUB_TOKEN,
|
||||
});
|
||||
const owner = 'actualbudget';
|
||||
const config = REPOSITORY_CONFIG.get(repo);
|
||||
|
||||
@@ -122,128 +129,134 @@ async function countContributorPoints(repo) {
|
||||
// Get all PRs using search
|
||||
const searchQuery = `repo:${owner}/${repo} is:pr is:merged merged:${since.toISOString()}..${until.toISOString()}`;
|
||||
const recentPRs = await octokit.paginate(
|
||||
octokit.search.issuesAndPullRequests,
|
||||
'GET /search/issues',
|
||||
{
|
||||
q: searchQuery,
|
||||
per_page: 100,
|
||||
advanced_search: true,
|
||||
},
|
||||
response => response.data,
|
||||
response => response.data.filter(pr => pr.number),
|
||||
);
|
||||
|
||||
// Get reviews and PR details for each PR
|
||||
for (const pr of recentPRs) {
|
||||
const { data: reviews } = await octokit.pulls.listReviews({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
|
||||
// Get list of modified files
|
||||
const { data: modifiedFiles } = await octokit.pulls.listFiles({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
|
||||
// Calculate points based on PR size, excluding specified files
|
||||
const totalChanges = modifiedFiles
|
||||
.filter(
|
||||
file =>
|
||||
!config.EXCLUDED_FILES.some(pattern =>
|
||||
minimatch(file.filename, pattern),
|
||||
await Promise.all(
|
||||
recentPRs.map(pr =>
|
||||
limit(async () => {
|
||||
const [reviews, modifiedFiles] = await Promise.all([
|
||||
octokit.pulls.listReviews({ owner, repo, pull_number: pr.number }),
|
||||
octokit.paginate(
|
||||
octokit.pulls.listFiles,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100,
|
||||
},
|
||||
res => res.data,
|
||||
),
|
||||
)
|
||||
.reduce((sum, file) => sum + file.additions + file.deletions, 0);
|
||||
]);
|
||||
|
||||
// Check if this is a release PR
|
||||
const isReleasePR = pr.title.match(/^🔖 \(\d+\.\d+\.\d+\)/);
|
||||
const totalChanges = modifiedFiles
|
||||
.filter(
|
||||
file =>
|
||||
!config.EXCLUDED_FILES.some(pattern =>
|
||||
minimatch(file.filename, pattern),
|
||||
),
|
||||
)
|
||||
.reduce((sum, file) => sum + file.additions + file.deletions, 0);
|
||||
|
||||
// Calculate points for reviewers based on PR size
|
||||
const prPoints = config.PR_REVIEW_POINT_TIERS.find(
|
||||
tier => totalChanges > tier.minChanges,
|
||||
).points;
|
||||
const isReleasePR = pr.title.match(/🔖.*\d+\.\d+\.\d+/);
|
||||
const prPoints =
|
||||
config.PR_REVIEW_POINT_TIERS.find(t => totalChanges >= t.minChanges)
|
||||
?.points ?? 0;
|
||||
|
||||
// Add points to the reviewers
|
||||
const uniqueReviewers = new Set();
|
||||
reviews
|
||||
.filter(
|
||||
review =>
|
||||
stats.has(review.user?.login) &&
|
||||
review.state === 'APPROVED' &&
|
||||
!uniqueReviewers.has(review.user?.login),
|
||||
)
|
||||
.forEach(({ user: { login: reviewer } }) => {
|
||||
uniqueReviewers.add(reviewer);
|
||||
const userStats = stats.get(reviewer);
|
||||
userStats.reviews.push({ pr: pr.number.toString(), points: prPoints });
|
||||
userStats.points += prPoints;
|
||||
});
|
||||
|
||||
// Award points to the PR creator if it's a release PR
|
||||
if (isReleasePR && stats.has(pr.user.login)) {
|
||||
const creatorStats = stats.get(pr.user.login);
|
||||
creatorStats.reviews.push({
|
||||
pr: pr.number.toString(),
|
||||
points: config.POINTS_PER_RELEASE_PR,
|
||||
isReleaseCreator: true,
|
||||
});
|
||||
creatorStats.points += config.POINTS_PER_RELEASE_PR;
|
||||
}
|
||||
}
|
||||
|
||||
// Get all issues with label events in the last month
|
||||
const issues = await octokit.paginate(
|
||||
octokit.issues.listForRepo,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
state: 'all',
|
||||
sort: 'updated',
|
||||
direction: 'desc',
|
||||
per_page: 100,
|
||||
since: since.toISOString(),
|
||||
},
|
||||
(response, done) =>
|
||||
response.data.filter(issue => new Date(issue.updated_at) <= until),
|
||||
if (isReleasePR) {
|
||||
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,
|
||||
isReleaseCreator: true,
|
||||
});
|
||||
creatorStats.points += config.POINTS_PER_RELEASE_PR;
|
||||
}
|
||||
} else {
|
||||
const uniqueReviewers = new Set();
|
||||
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);
|
||||
userStats.reviews.push({
|
||||
pr: pr.number.toString(),
|
||||
points: prPoints,
|
||||
});
|
||||
userStats.points += prPoints;
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Get all issues with label events in the last month
|
||||
const issues = await octokit.paginate(octokit.issues.listForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'all',
|
||||
sort: 'updated',
|
||||
direction: 'desc',
|
||||
per_page: 100,
|
||||
since: since.toISOString(),
|
||||
});
|
||||
|
||||
// Get label events for each issue
|
||||
for (const issue of issues) {
|
||||
const { data: events } = await octokit.issues.listEventsForTimeline({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
});
|
||||
await Promise.all(
|
||||
issues.map(issue =>
|
||||
limit(async () => {
|
||||
const { data: events } = await octokit.issues.listEventsForTimeline({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
});
|
||||
|
||||
// Process events
|
||||
events
|
||||
.filter(
|
||||
event =>
|
||||
new Date(event.created_at) > since &&
|
||||
new Date(event.created_at) <= until &&
|
||||
stats.has(event.actor?.login),
|
||||
)
|
||||
.forEach(event => {
|
||||
if (
|
||||
event.event === 'unlabeled' &&
|
||||
event.label &&
|
||||
event.label.name.toLowerCase() === 'needs triage'
|
||||
) {
|
||||
const remover = event.actor.login;
|
||||
const userStats = stats.get(remover);
|
||||
userStats.labelRemovals.push(issue.number.toString());
|
||||
userStats.points += config.POINTS_PER_ISSUE_TRIAGE_ACTION;
|
||||
}
|
||||
events
|
||||
.filter(event => {
|
||||
const createdAt = new Date(event.created_at);
|
||||
return (
|
||||
createdAt.getTime() > since.getTime() &&
|
||||
createdAt.getTime() <= until.getTime() &&
|
||||
stats.has(event.actor?.login)
|
||||
);
|
||||
})
|
||||
.forEach(event => {
|
||||
if (
|
||||
event.event === 'unlabeled' &&
|
||||
event.label?.name.toLowerCase() === 'needs triage'
|
||||
) {
|
||||
const remover = event.actor.login;
|
||||
const userStats = stats.get(remover);
|
||||
userStats.labelRemovals.push(issue.number.toString());
|
||||
userStats.points += config.POINTS_PER_ISSUE_TRIAGE_ACTION;
|
||||
}
|
||||
|
||||
if (event.event === 'closed') {
|
||||
const closer = event.actor.login;
|
||||
const userStats = stats.get(closer);
|
||||
userStats.issueClosings.push(issue.number.toString());
|
||||
userStats.points += config.POINTS_PER_ISSUE_CLOSING_ACTION;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (
|
||||
event.event === 'closed' &&
|
||||
event.state_reason === 'not_planned'
|
||||
) {
|
||||
const closer = event.actor.login;
|
||||
const userStats = stats.get(closer);
|
||||
userStats.issueClosings.push(issue.number.toString());
|
||||
userStats.points += config.POINTS_PER_ISSUE_CLOSING_ACTION;
|
||||
}
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Print all statistics
|
||||
printStats(
|
||||
|
||||
89
.github/workflows/ai-generated-release-notes.yml
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
name: Generate Release Notes from CodeRabbit summary
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
generate-release-notes:
|
||||
# Only run on PR comments from CodeRabbit bot
|
||||
if: github.event.issue.pull_request && github.event.comment.user.login == 'coderabbitai[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Check if this is CodeRabbit's first comment
|
||||
id: check-first-comment
|
||||
run: node .github/actions/ai-generated-release-notes/check-first-comment.js
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }}
|
||||
|
||||
- name: Get PR details
|
||||
if: steps.check-first-comment.outputs.result == 'true'
|
||||
id: pr-details
|
||||
run: node .github/actions/ai-generated-release-notes/pr-details.js
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
|
||||
- name: Check if release notes file already exists
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.pr-details.outputs.result != 'null'
|
||||
id: check-release-notes-exists
|
||||
run: node .github/actions/ai-generated-release-notes/check-release-notes-exists.js
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
PR_DETAILS: ${{ steps.pr-details.outputs.result }}
|
||||
|
||||
- name: Generate summary with OpenAI
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false'
|
||||
id: generate-summary
|
||||
run: node .github/actions/ai-generated-release-notes/generate-summary.js
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GITHUB_EVENT_COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
PR_DETAILS: ${{ steps.pr-details.outputs.result }}
|
||||
|
||||
- name: Determine category with OpenAI
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null'
|
||||
id: determine-category
|
||||
run: node .github/actions/ai-generated-release-notes/determine-category.js
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GITHUB_EVENT_COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
PR_DETAILS: ${{ steps.pr-details.outputs.result }}
|
||||
SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }}
|
||||
|
||||
- name: Create and commit release notes file via GitHub API
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null' && steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
|
||||
run: node .github/actions/ai-generated-release-notes/create-release-notes-file.js
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }}
|
||||
CATEGORY: ${{ steps.determine-category.outputs.result }}
|
||||
|
||||
- name: Comment on PR
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null' && steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
|
||||
run: node .github/actions/ai-generated-release-notes/comment-on-pr.js
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }}
|
||||
CATEGORY: ${{ steps.determine-category.outputs.result }}
|
||||
10
.github/workflows/docker-edge.yml
vendored
@@ -22,9 +22,9 @@ permissions:
|
||||
|
||||
env:
|
||||
IMAGES: |
|
||||
actualbudget/actual-server
|
||||
ghcr.io/actualbudget/actual-server
|
||||
ghcr.io/actualbudget/actual
|
||||
${{ !github.event.repository.fork && 'actualbudget/actual-server' || '' }}
|
||||
ghcr.io/${{ github.repository_owner }}/actual-server
|
||||
ghcr.io/${{ github.repository_owner }}/actual
|
||||
|
||||
# Creates the following tags:
|
||||
# - actual-server:edge
|
||||
@@ -34,7 +34,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.event.repository.fork == false }}
|
||||
if: github.event_name == 'workflow_dispatch' || !github.event.repository.fork
|
||||
name: Build Docker image
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request' && !github.event.repository.fork
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
2
.github/workflows/electron-master.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- ubuntu-22.04
|
||||
- windows-latest
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
2
.github/workflows/electron-pr.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- ubuntu-22.04
|
||||
- windows-latest
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
25
.github/workflows/generate-release-pr.yml
vendored
@@ -24,8 +24,29 @@ jobs:
|
||||
id: bump_package_versions
|
||||
shell: bash
|
||||
run: |
|
||||
.github/actions/bump-package-versions ${{ github.event.inputs.version }}
|
||||
echo "version=$(jq -r .version packages/desktop-client/package.json)" > $GITHUB_OUTPUT
|
||||
declare -A packages=(
|
||||
[web]="desktop-client"
|
||||
[electron]="desktop-electron"
|
||||
[sync]="sync-server"
|
||||
[api]="api"
|
||||
)
|
||||
|
||||
for key in "${!packages[@]}"; do
|
||||
pkg="${packages[$key]}"
|
||||
|
||||
if [[ -n "${{ github.event.inputs.version }}" ]]; then
|
||||
version="${{ github.event.inputs.version }}"
|
||||
else
|
||||
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
|
||||
--package-json "./packages/$pkg/package.json" \
|
||||
--type auto \
|
||||
--update)
|
||||
fi
|
||||
|
||||
eval "NEW_${key^^}_VERSION=\"$version\""
|
||||
done
|
||||
|
||||
echo "version=$NEW_WEB_VERSION" >> "$GITHUB_OUTPUT"
|
||||
- name: Create PR
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
body: |
|
||||
:sparkles: Thanks for sharing your idea! :sparkles:
|
||||
|
||||
This repository uses lodash style issue management for enhancements. That means enhancement issues are automatically closed. This doesn’t mean we don’t accept feature requests, though! We will consider implementing ones that receive many upvotes, and we welcome contributions for any feature requests marked as needing votes (just post a comment first so we can help you make a successful contribution).
|
||||
This repository uses a voting-based system for feature requests. While enhancement issues are automatically closed, we still welcome feature requests! The voting system helps us gauge community interest in potential features. We also encourage community contributions for any feature requests marked as needing votes (just post a comment first so we can help guide you toward a successful contribution).
|
||||
|
||||
The enhancement backlog can be found here: https://github.com/actualbudget/actual/issues?q=label%3A%22needs+votes%22+sort%3Areactions-%2B1-desc+
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ jobs:
|
||||
build-and-pack:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and pack npm packages
|
||||
if: github.event.repository.fork == false
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -19,9 +20,9 @@ jobs:
|
||||
- name: Update package versions
|
||||
run: |
|
||||
# Get new nightly versions
|
||||
NEW_WEB_VERSION=$(node ./.github/actions/get-next-package-version.js --package-json ./packages/desktop-client/package.json --type nightly)
|
||||
NEW_SYNC_VERSION=$(node ./.github/actions/get-next-package-version.js --package-json ./packages/sync-server/package.json --type nightly)
|
||||
NEW_API_VERSION=$(node ./.github/actions/get-next-package-version.js --package-json ./packages/api/package.json --type nightly)
|
||||
NEW_WEB_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-client/package.json --type nightly)
|
||||
NEW_SYNC_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/sync-server/package.json --type nightly)
|
||||
NEW_API_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/api/package.json --type nightly)
|
||||
|
||||
# Set package versions
|
||||
npm version $NEW_WEB_VERSION --no-git-tag-version --workspace=@actual-app/web --no-workspaces-update
|
||||
|
||||
2
.github/workflows/size-compare.yml
vendored
@@ -81,7 +81,7 @@ jobs:
|
||||
base-stats-json-path: ./base/web-stats.json
|
||||
title: desktop-client
|
||||
|
||||
- uses: github/webpack-bundlesize-compare-action@v2.1.0
|
||||
- uses: twk3/rollup-size-compare-action@v1.1.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
current-stats-json-path: ./head/loot-core-stats.json
|
||||
|
||||
3
.gitignore
vendored
@@ -60,3 +60,6 @@ fly.toml
|
||||
|
||||
# TypeScript cache
|
||||
build/
|
||||
|
||||
# .d.ts files aren't type-checked with skipLibCheck set to true
|
||||
*.d.ts
|
||||
|
||||
@@ -7,6 +7,8 @@ packages/api/migrations
|
||||
packages/crdt/dist
|
||||
packages/component-library/src/icons/**/*
|
||||
packages/desktop-client/bundle.browser.js
|
||||
packages/desktop-client/stats.json
|
||||
packages/desktop-client/.swc/
|
||||
packages/desktop-client/build/
|
||||
packages/desktop-client/locale/
|
||||
packages/desktop-client/build-electron/
|
||||
@@ -20,10 +22,9 @@ packages/desktop-client/playwright-report/
|
||||
packages/desktop-electron/client-build/
|
||||
packages/desktop-electron/build/
|
||||
packages/desktop-electron/dist/
|
||||
packages/import-ynab4/**/node_modules/*
|
||||
packages/import-ynab5/**/node_modules/*
|
||||
packages/loot-core/**/node_modules/*
|
||||
packages/loot-core/**/lib-dist/*
|
||||
packages/loot-core/**/proto/*
|
||||
packages/sync-server/coverage/
|
||||
.yarn/*
|
||||
upcoming-release-notes/*
|
||||
|
||||
10
CODEOWNERS
Normal file
@@ -0,0 +1,10 @@
|
||||
# CODEOWNERS file for Actual Budget
|
||||
# Please add your name to code-paths that you feel especially
|
||||
# passionate about. You will be notified for any PRs there.
|
||||
|
||||
/packages/api/ @MatissJanis
|
||||
/packages/component-library/ @MatissJanis
|
||||
/packages/desktop-client/src/components/mobile @joel-jeremy
|
||||
/packages/desktop-electron/ @MikesGlitch
|
||||
/packages/loot-core/src/server/budget @youngcw
|
||||
/packages/sync-server/ @matt-fidd
|
||||
@@ -14,6 +14,8 @@ git pull
|
||||
popd > /dev/null
|
||||
packages/desktop-client/bin/remove-untranslated-languages
|
||||
|
||||
export NODE_OPTIONS="--max-old-space-size=4096"
|
||||
|
||||
yarn workspace loot-core build:browser
|
||||
yarn workspace @actual-app/web build:browser
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ git pull
|
||||
popd > /dev/null
|
||||
packages/desktop-client/bin/remove-untranslated-languages
|
||||
|
||||
export NODE_OPTIONS="--max-old-space-size=4096"
|
||||
|
||||
yarn workspace loot-core build:node
|
||||
yarn workspace @actual-app/web build --mode=desktop # electron specific build
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import prompts from 'prompts';
|
||||
|
||||
async function run() {
|
||||
const username = await execAsync(
|
||||
// eslint-disable-next-line rulesdir/typography
|
||||
// eslint-disable-next-line actual/typography
|
||||
"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`.',
|
||||
);
|
||||
|
||||
@@ -1,29 +1,15 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
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 pluginRulesDir from 'eslint-plugin-rulesdir';
|
||||
import pluginTypescript from 'typescript-eslint';
|
||||
import pluginTypescriptPaths from 'eslint-plugin-typescript-paths';
|
||||
import pluginActual from './packages/eslint-plugin-actual/lib/index.js';
|
||||
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
pluginRulesDir.RULES_DIR = path.join(
|
||||
__dirname,
|
||||
'packages',
|
||||
'eslint-plugin-actual',
|
||||
'lib',
|
||||
'rules',
|
||||
);
|
||||
|
||||
const confusingBrowserGlobals = [
|
||||
// https://github.com/facebook/create-react-app/tree/main/packages/confusing-browser-globals
|
||||
'addEventListener',
|
||||
@@ -167,14 +153,19 @@ export default pluginTypescript.config(
|
||||
pluginImport.flatConfigs.recommended,
|
||||
{
|
||||
plugins: {
|
||||
'react-hooks': pluginReactHooks,
|
||||
'jsx-a11y': pluginJSXA11y,
|
||||
rulesdir: pluginRulesDir,
|
||||
'typescript-paths': pluginTypescriptPaths,
|
||||
actual: pluginActual,
|
||||
},
|
||||
rules: {
|
||||
'actual/no-untranslated-strings': 'error',
|
||||
'actual/prefer-trans-over-t': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.{js,ts,jsx,tsx}'],
|
||||
plugins: {
|
||||
'jsx-a11y': pluginJSXA11y,
|
||||
'react-hooks': pluginReactHooks,
|
||||
},
|
||||
rules: {
|
||||
// http://eslint.org/docs/rules/
|
||||
'array-callback-return': 'warn',
|
||||
@@ -458,8 +449,9 @@ export default pluginTypescript.config(
|
||||
},
|
||||
],
|
||||
|
||||
'rulesdir/typography': 'warn',
|
||||
'rulesdir/prefer-if-statement': 'warn',
|
||||
'actual/typography': 'warn',
|
||||
'actual/prefer-if-statement': 'warn',
|
||||
'actual/prefer-logger-over-console': 'error',
|
||||
|
||||
// Note: base rule explicitly disabled in favor of the TS one
|
||||
'no-unused-vars': 'off',
|
||||
@@ -467,6 +459,7 @@ export default pluginTypescript.config(
|
||||
'warn',
|
||||
{
|
||||
varsIgnorePattern: '^(_|React)',
|
||||
argsIgnorePattern: '^(_|React)',
|
||||
ignoreRestSiblings: true,
|
||||
caughtErrors: 'none',
|
||||
},
|
||||
@@ -505,7 +498,7 @@ export default pluginTypescript.config(
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: 'react-router-dom',
|
||||
name: 'react-router',
|
||||
importNames: ['useNavigate'],
|
||||
message:
|
||||
"Please import Actual's useNavigate() hook from `src/hooks` instead.",
|
||||
@@ -639,6 +632,9 @@ export default pluginTypescript.config(
|
||||
},
|
||||
{
|
||||
files: ['packages/desktop-client/**/*.{js,ts,jsx,tsx}'],
|
||||
plugins: {
|
||||
'typescript-paths': pluginTypescriptPaths,
|
||||
},
|
||||
rules: {
|
||||
'typescript-paths/absolute-parent-import': [
|
||||
'error',
|
||||
@@ -778,7 +774,9 @@ export default pluginTypescript.config(
|
||||
],
|
||||
|
||||
rules: {
|
||||
'rulesdir/typography': 'off',
|
||||
'actual/typography': 'off',
|
||||
'actual/no-untranslated-strings': 'off',
|
||||
'actual/prefer-logger-over-console': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -797,7 +795,7 @@ export default pluginTypescript.config(
|
||||
// TODO: fix the issues in these files
|
||||
rules: {
|
||||
'import/extensions': 'off',
|
||||
'rulesdir/typography': 'off',
|
||||
'actual/typography': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -805,8 +803,6 @@ export default pluginTypescript.config(
|
||||
rules: {
|
||||
'import/no-anonymous-default-export': 'off',
|
||||
'import/no-default-export': 'off',
|
||||
// can be re-enabled after https://github.com/actualbudget/actual/pull/4253
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
17
package.json
@@ -41,6 +41,7 @@
|
||||
"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 workspaces foreach --all --parallel --verbose run vrt",
|
||||
"vrt:docker": "./bin/run-vrt",
|
||||
"rebuild-electron": "./node_modules/.bin/electron-rebuild -f -m ./packages/loot-core",
|
||||
@@ -54,18 +55,17 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@types/node": "^22.15.18",
|
||||
"@types/node": "^22.17.0",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@typescript-eslint/parser": "^8.32.1",
|
||||
"@typescript-eslint/parser": "^8.42.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9.27.0",
|
||||
"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": "^5.2.0",
|
||||
"eslint-plugin-rulesdir": "^0.2.2",
|
||||
"eslint-plugin-react-hooks": "^6.0.0-rc.2",
|
||||
"eslint-plugin-typescript-paths": "^0.0.33",
|
||||
"globals": "^15.15.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
@@ -74,12 +74,13 @@
|
||||
"minimatch": "^10.0.3",
|
||||
"node-jq": "^6.0.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"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.8.3",
|
||||
"typescript-eslint": "^8.32.1",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.42.0",
|
||||
"typescript-strict-plugin": "^2.4.4"
|
||||
},
|
||||
"resolutions": {
|
||||
@@ -98,7 +99,7 @@
|
||||
},
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"browserslist": [
|
||||
"electron 24.0",
|
||||
"electron >= 35.0",
|
||||
"defaults"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -42,7 +42,11 @@ export async function init(config: InitConfig = {}) {
|
||||
|
||||
export async function shutdown() {
|
||||
if (actualApp) {
|
||||
await actualApp.send('sync');
|
||||
try {
|
||||
await actualApp.send('sync');
|
||||
} catch (e) {
|
||||
// most likely that no budget is loaded, so the sync failed
|
||||
}
|
||||
await actualApp.send('close-budget');
|
||||
actualApp = null;
|
||||
}
|
||||
|
||||
@@ -740,3 +740,122 @@ describe('API CRUD operations', () => {
|
||||
expect(transactions[0].notes).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
//apis: createSchedule, getSchedules, updateSchedule, deleteSchedule
|
||||
test('Schedules: successfully complete schedules operations', async () => {
|
||||
await api.loadBudget(budgetName);
|
||||
//test a schedule with a recuring configuration
|
||||
const ScheduleId1 = await api.createSchedule({
|
||||
name: 'test-schedule 1',
|
||||
posts_transaction: true,
|
||||
// amount: -5000,
|
||||
amountOp: 'is',
|
||||
date: {
|
||||
frequency: 'monthly',
|
||||
interval: 1,
|
||||
start: '2025-06-13',
|
||||
patterns: [],
|
||||
skipWeekend: false,
|
||||
weekendSolveMode: 'after',
|
||||
endMode: 'never',
|
||||
},
|
||||
});
|
||||
//test the creation of non recurring schedule
|
||||
const ScheduleId2 = await api.createSchedule({
|
||||
name: 'test-schedule 2',
|
||||
posts_transaction: false,
|
||||
amount: 4000,
|
||||
amountOp: 'is',
|
||||
date: '2025-06-13',
|
||||
});
|
||||
let schedules = await api.getSchedules();
|
||||
|
||||
// Schedules successfully created
|
||||
expect(schedules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'test-schedule 1',
|
||||
posts_transaction: true,
|
||||
// amount: -5000,
|
||||
amountOp: 'is',
|
||||
date: {
|
||||
frequency: 'monthly',
|
||||
interval: 1,
|
||||
start: '2025-06-13',
|
||||
patterns: [],
|
||||
skipWeekend: false,
|
||||
weekendSolveMode: 'after',
|
||||
endMode: 'never',
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'test-schedule 2',
|
||||
posts_transaction: false,
|
||||
amount: 4000,
|
||||
amountOp: 'is',
|
||||
date: '2025-06-13',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
//check getIDByName works on schedules
|
||||
expect(await api.getIDByName('schedules', 'test-schedule 1')).toEqual(
|
||||
ScheduleId1,
|
||||
);
|
||||
expect(await api.getIDByName('schedules', 'test-schedule 2')).toEqual(
|
||||
ScheduleId2,
|
||||
);
|
||||
|
||||
//check getIDByName works on accounts
|
||||
const schedAccountId1 = await api.createAccount(
|
||||
{ name: 'sched-test-account1', offbudget: true },
|
||||
1000,
|
||||
);
|
||||
|
||||
expect(await api.getIDByName('accounts', 'sched-test-account1')).toEqual(
|
||||
schedAccountId1,
|
||||
);
|
||||
|
||||
//check getIDByName works on payees
|
||||
const schedPayeeId1 = await api.createPayee({ name: 'sched-test-payee1' });
|
||||
|
||||
expect(await api.getIDByName('payees', 'sched-test-payee1')).toEqual(
|
||||
schedPayeeId1,
|
||||
);
|
||||
await api.updateSchedule(ScheduleId1, {
|
||||
amount: -10000,
|
||||
account: schedAccountId1,
|
||||
});
|
||||
await api.deleteSchedule(ScheduleId2);
|
||||
|
||||
// schedules successfully updated, and one of them deleted
|
||||
await api.updateSchedule(ScheduleId1, {
|
||||
amount: -10000,
|
||||
account: schedAccountId1,
|
||||
payee: schedPayeeId1,
|
||||
});
|
||||
await api.deleteSchedule(ScheduleId2);
|
||||
|
||||
schedules = await api.getSchedules();
|
||||
expect(schedules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: ScheduleId1,
|
||||
posts_transaction: true,
|
||||
amount: -10000,
|
||||
account: schedAccountId1,
|
||||
payee: schedPayeeId1,
|
||||
amountOp: 'is',
|
||||
date: {
|
||||
frequency: 'monthly',
|
||||
interval: 1,
|
||||
start: '2025-06-13',
|
||||
patterns: [],
|
||||
skipWeekend: false,
|
||||
weekendSolveMode: 'after',
|
||||
endMode: 'never',
|
||||
},
|
||||
}),
|
||||
expect.not.objectContaining({ id: ScheduleId2 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -96,6 +96,7 @@ export function addTransactions(
|
||||
|
||||
export interface ImportTransactionsOpts {
|
||||
defaultCleared?: boolean;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
export function importTransactions(
|
||||
@@ -103,11 +104,13 @@ export function importTransactions(
|
||||
transactions: ImportTransactionEntity[],
|
||||
opts: ImportTransactionsOpts = {
|
||||
defaultCleared: true,
|
||||
dryRun: false,
|
||||
},
|
||||
) {
|
||||
return send('api/transactions-import', {
|
||||
accountId,
|
||||
transactions,
|
||||
isPreview: opts.dryRun,
|
||||
opts,
|
||||
});
|
||||
}
|
||||
@@ -239,3 +242,31 @@ export function holdBudgetForNextMonth(month, amount) {
|
||||
export function resetBudgetHold(month) {
|
||||
return send('api/budget-reset-hold', { month });
|
||||
}
|
||||
|
||||
export function createSchedule(schedule) {
|
||||
return send('api/schedule-create', schedule);
|
||||
}
|
||||
|
||||
export function updateSchedule(id, fields, resetNextDate?: boolean) {
|
||||
return send('api/schedule-update', {
|
||||
id,
|
||||
fields,
|
||||
resetNextDate,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteSchedule(scheduleId) {
|
||||
return send('api/schedule-delete', scheduleId);
|
||||
}
|
||||
|
||||
export function getSchedules() {
|
||||
return send('api/schedules-get');
|
||||
}
|
||||
|
||||
export function getIDByName(type, name) {
|
||||
return send('api/get-id-by-name', { type, name });
|
||||
}
|
||||
|
||||
export function getServerVersion() {
|
||||
return send('api/get-server-version');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/api",
|
||||
"version": "25.6.1",
|
||||
"version": "25.9.0",
|
||||
"license": "MIT",
|
||||
"description": "An API for Actual",
|
||||
"engines": {
|
||||
@@ -24,14 +24,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@actual-app/crdt": "workspace:^",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"compare-versions": "^6.1.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsc-alias": "^1.8.16",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.1.3"
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"outDir": "dist",
|
||||
"declarationDir": "@types",
|
||||
"paths": {
|
||||
"loot-core/*": ["./@types/loot-core/*"]
|
||||
"loot-core/*": ["./@types/loot-core/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["."],
|
||||
|
||||
73
packages/ci-actions/bin/get-next-package-version.js
Executable file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// This script is used in GitHub Actions to get the next version based on the current package.json version.
|
||||
// It supports three types of versioning: nightly, hotfix, and monthly.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { parseArgs } from 'node:util';
|
||||
|
||||
// eslint-disable-next-line import/extensions
|
||||
import { getNextVersion } from '../src/versions/get-next-package-version.js';
|
||||
|
||||
const args = process.argv;
|
||||
|
||||
const options = {
|
||||
'package-json': {
|
||||
type: 'string',
|
||||
short: 'p',
|
||||
},
|
||||
type: {
|
||||
type: 'string', // nightly, hotfix, monthly, auto
|
||||
short: 't',
|
||||
},
|
||||
update: {
|
||||
type: 'boolean',
|
||||
short: 'u',
|
||||
default: false,
|
||||
},
|
||||
};
|
||||
|
||||
const { values } = parseArgs({
|
||||
args,
|
||||
options,
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
if (!values['package-json']) {
|
||||
console.error(
|
||||
'Please specify the path to package.json using --package-json or -p option.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJsonPath = values['package-json'];
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const currentVersion = packageJson.version;
|
||||
|
||||
let newVersion;
|
||||
try {
|
||||
newVersion = getNextVersion({
|
||||
currentVersion,
|
||||
type: values.type,
|
||||
currentDate: new Date(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(newVersion);
|
||||
|
||||
if (values.update) {
|
||||
packageJson.version = newVersion;
|
||||
fs.writeFileSync(
|
||||
packageJsonPath,
|
||||
JSON.stringify(packageJson, null, 2) + '\n',
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
11
packages/ci-actions/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@actual-app/ci-actions",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest"
|
||||
}
|
||||
}
|
||||
72
packages/ci-actions/src/versions/get-next-package-version.js
Normal file
@@ -0,0 +1,72 @@
|
||||
function parseVersion(version) {
|
||||
const [y, m, p] = version.split('.');
|
||||
return {
|
||||
versionYear: parseInt(y, 10),
|
||||
versionMonth: parseInt(m, 10),
|
||||
versionHotfix: parseInt(p, 10),
|
||||
};
|
||||
}
|
||||
|
||||
function computeNextMonth(versionYear, versionMonth) {
|
||||
// Create date and add 1 month
|
||||
const versionDate = new Date(2000 + versionYear, versionMonth - 1, 1); // month is 0-indexed
|
||||
const nextVersionMonthDate = new Date(
|
||||
versionDate.getFullYear(),
|
||||
versionDate.getMonth() + 1,
|
||||
1,
|
||||
);
|
||||
|
||||
// Format back to YY.M format
|
||||
const fullYear = nextVersionMonthDate.getFullYear();
|
||||
const nextVersionYear = fullYear.toString().slice(fullYear < 2100 ? -2 : -3);
|
||||
const nextVersionMonth = nextVersionMonthDate.getMonth() + 1; // Convert back to 1-indexed
|
||||
return { nextVersionYear, nextVersionMonth };
|
||||
}
|
||||
|
||||
// Determine logical type from 'auto' based on the current date and version
|
||||
function resolveType(type, currentDate, versionYear, versionMonth) {
|
||||
if (type !== 'auto') return type;
|
||||
const inPatchMonth =
|
||||
currentDate.getFullYear() === 2000 + versionYear &&
|
||||
currentDate.getMonth() + 1 === versionMonth;
|
||||
if (inPatchMonth && currentDate.getDate() <= 25) return 'hotfix';
|
||||
return 'monthly';
|
||||
}
|
||||
|
||||
export function getNextVersion({
|
||||
currentVersion,
|
||||
type,
|
||||
currentDate = new Date(),
|
||||
}) {
|
||||
const { versionYear, versionMonth, versionHotfix } =
|
||||
parseVersion(currentVersion);
|
||||
const { nextVersionYear, nextVersionMonth } = computeNextMonth(
|
||||
versionYear,
|
||||
versionMonth,
|
||||
);
|
||||
const resolvedType = resolveType(
|
||||
type,
|
||||
currentDate,
|
||||
versionYear,
|
||||
versionMonth,
|
||||
);
|
||||
|
||||
// Format date stamp once for nightly
|
||||
const currentDateString = currentDate
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
.replaceAll('-', '');
|
||||
|
||||
switch (resolvedType) {
|
||||
case 'nightly':
|
||||
return `${nextVersionYear}.${nextVersionMonth}.0-nightly.${currentDateString}`;
|
||||
case 'hotfix':
|
||||
return `${versionYear}.${versionMonth}.${versionHotfix + 1}`;
|
||||
case 'monthly':
|
||||
return `${nextVersionYear}.${nextVersionMonth}.0`;
|
||||
default:
|
||||
throw new Error(
|
||||
'Invalid type specified. Use “auto”, “nightly”, “hotfix”, or “monthly”.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getNextVersion } from './get-next-package-version';
|
||||
|
||||
describe('getNextVersion (lib)', () => {
|
||||
it('hotfix increments patch', () => {
|
||||
expect(
|
||||
getNextVersion({
|
||||
currentVersion: '25.8.1',
|
||||
type: 'hotfix',
|
||||
currentDate: new Date('2025-08-10'),
|
||||
}),
|
||||
).toBe('25.8.2');
|
||||
});
|
||||
|
||||
it('monthly advances month same year', () => {
|
||||
expect(
|
||||
getNextVersion({
|
||||
currentVersion: '25.8.3',
|
||||
type: 'monthly',
|
||||
currentDate: new Date('2025-08-15'),
|
||||
}),
|
||||
).toBe('25.9.0');
|
||||
});
|
||||
|
||||
it('monthly wraps year December -> January', () => {
|
||||
expect(
|
||||
getNextVersion({
|
||||
currentVersion: '25.12.3',
|
||||
type: 'monthly',
|
||||
currentDate: new Date('2025-12-05'),
|
||||
}),
|
||||
).toBe('26.1.0');
|
||||
});
|
||||
|
||||
it('nightly format with date stamp', () => {
|
||||
expect(
|
||||
getNextVersion({
|
||||
currentVersion: '25.8.1',
|
||||
type: 'nightly',
|
||||
currentDate: new Date('2025-08-22'),
|
||||
}),
|
||||
).toBe('25.9.0-nightly.20250822');
|
||||
});
|
||||
|
||||
it('auto before 25th -> hotfix', () => {
|
||||
expect(
|
||||
getNextVersion({
|
||||
currentVersion: '25.8.4',
|
||||
type: 'auto',
|
||||
currentDate: new Date('2025-08-20'),
|
||||
}),
|
||||
).toBe('25.8.5');
|
||||
});
|
||||
|
||||
it('auto after 25th (same month) -> monthly', () => {
|
||||
expect(
|
||||
getNextVersion({
|
||||
currentVersion: '25.8.4',
|
||||
type: 'auto',
|
||||
currentDate: new Date('2025-08-27'),
|
||||
}),
|
||||
).toBe('25.9.0');
|
||||
});
|
||||
|
||||
it('auto after 25th (next month) -> monthly', () => {
|
||||
expect(
|
||||
getNextVersion({
|
||||
currentVersion: '25.8.4',
|
||||
type: 'auto',
|
||||
currentDate: new Date('2025-09-02'),
|
||||
}),
|
||||
).toBe('25.9.0');
|
||||
});
|
||||
|
||||
it('invalid type throws', () => {
|
||||
expect(() =>
|
||||
getNextVersion({
|
||||
currentVersion: '25.8.4',
|
||||
type: 'unknown',
|
||||
currentDate: new Date('2025-08-10'),
|
||||
}),
|
||||
).toThrow(/Invalid type/);
|
||||
});
|
||||
});
|
||||
9
packages/ci-actions/vitest.config.mts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
include: ['src/**/*.test.(js|jsx|ts|tsx)'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -13,10 +13,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/cli": "^8.1.0",
|
||||
"@types/react": "^19.1.4",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"vitest": "^3.1.3"
|
||||
"@types/react": "^19.1.12",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"exports": {
|
||||
"./hooks/*": "./src/hooks/*.ts",
|
||||
@@ -48,7 +48,8 @@
|
||||
"./tokens": "./src/tokens.ts",
|
||||
"./toggle": "./src/Toggle.tsx",
|
||||
"./tooltip": "./src/Tooltip.tsx",
|
||||
"./view": "./src/View.tsx"
|
||||
"./view": "./src/View.tsx",
|
||||
"./color-picker": "./src/ColorPicker.tsx"
|
||||
},
|
||||
"scripts": {
|
||||
"generate:icons": "rm src/icons/*/*.tsx; cd src/icons && svgr --template template.ts --index-template index-template.ts --typescript --expand-props start -d . .",
|
||||
|
||||
180
packages/component-library/src/ColorPicker.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { ChangeEvent, ReactNode } from 'react';
|
||||
import {
|
||||
ColorPicker as AriaColorPicker,
|
||||
ColorPickerProps as AriaColorPickerProps,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
ColorSwatch as AriaColorSwatch,
|
||||
ColorSwatchProps,
|
||||
ColorSwatchPicker as AriaColorSwatchPicker,
|
||||
ColorSwatchPickerItem,
|
||||
ColorField,
|
||||
parseColor,
|
||||
} from 'react-aria-components';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { Input } from './Input';
|
||||
import { Popover } from './Popover';
|
||||
|
||||
function ColorSwatch(props: ColorSwatchProps) {
|
||||
return (
|
||||
<AriaColorSwatch
|
||||
{...props}
|
||||
style={({ color }) => ({
|
||||
background: color.toString('hex'),
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '4px',
|
||||
boxShadow: 'inset 0 0 0 1px rgba(0, 0, 0, 0.1)',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// colors from https://materialui.co/colors
|
||||
const DEFAULT_COLOR_SET = [
|
||||
'#690CB0',
|
||||
'#D32F2F',
|
||||
'#C2185B',
|
||||
'#7B1FA2',
|
||||
'#512DA8',
|
||||
'#303F9F',
|
||||
'#1976D2',
|
||||
'#0288D1',
|
||||
'#0097A7',
|
||||
'#00796B',
|
||||
'#388E3C',
|
||||
'#689F38',
|
||||
'#AFB42B',
|
||||
'#FBC02D',
|
||||
'#FFA000',
|
||||
'#F57C00',
|
||||
'#E64A19',
|
||||
'#5D4037',
|
||||
'#616161',
|
||||
'#455A64',
|
||||
];
|
||||
|
||||
interface ColorSwatchPickerProps {
|
||||
columns?: number;
|
||||
colorset?: string[];
|
||||
}
|
||||
|
||||
function ColorSwatchPicker({
|
||||
columns = 5,
|
||||
colorset = DEFAULT_COLOR_SET,
|
||||
}: ColorSwatchPickerProps) {
|
||||
const pickers = [];
|
||||
|
||||
for (let l = 0; l < colorset.length / columns; l++) {
|
||||
const pickerItems = [];
|
||||
|
||||
for (let c = 0; c < columns; c++) {
|
||||
const color = colorset[columns * l + c];
|
||||
if (!color) {
|
||||
break;
|
||||
}
|
||||
|
||||
pickerItems.push(
|
||||
<ColorSwatchPickerItem
|
||||
key={color}
|
||||
color={color}
|
||||
className={css({
|
||||
position: 'relative',
|
||||
outline: 'none',
|
||||
borderRadius: '4px',
|
||||
width: 'fit-content',
|
||||
forcedColorAdjust: 'none',
|
||||
cursor: 'pointer',
|
||||
|
||||
'&[data-selected]::after': {
|
||||
// eslint-disable-next-line actual/typography
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
border: '2px solid black',
|
||||
outline: '2px solid white',
|
||||
outlineOffset: '-4px',
|
||||
borderRadius: 'inherit',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<ColorSwatch />
|
||||
</ColorSwatchPickerItem>,
|
||||
);
|
||||
}
|
||||
|
||||
pickers.push(
|
||||
<AriaColorSwatchPicker
|
||||
key={`colorset-${l}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{pickerItems}
|
||||
</AriaColorSwatchPicker>,
|
||||
);
|
||||
}
|
||||
|
||||
return pickers;
|
||||
}
|
||||
const isColor = (value: string) => /^#[0-9a-fA-F]{6}$/.test(value);
|
||||
|
||||
interface ColorPickerProps extends AriaColorPickerProps {
|
||||
children?: ReactNode;
|
||||
columns?: number;
|
||||
colorset?: string[];
|
||||
}
|
||||
|
||||
export function ColorPicker({
|
||||
children,
|
||||
columns,
|
||||
colorset,
|
||||
...props
|
||||
}: ColorPickerProps) {
|
||||
const onInput = (value: string) => {
|
||||
if (!isColor(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const color = parseColor(value);
|
||||
if (color) {
|
||||
props.onChange?.(color);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AriaColorPicker defaultValue={props.defaultValue ?? '#690CB0'} {...props}>
|
||||
<DialogTrigger>
|
||||
{children}
|
||||
<Popover>
|
||||
<Dialog
|
||||
style={{
|
||||
outline: 'none',
|
||||
padding: '15px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
minWidth: '192px',
|
||||
maxHeight: 'inherit',
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<ColorSwatchPicker columns={columns} colorset={colorset} />
|
||||
<ColorField
|
||||
onInput={({ target: { value } }: ChangeEvent<HTMLInputElement>) =>
|
||||
onInput(value)
|
||||
}
|
||||
>
|
||||
<Input placeholder="#RRGGBB" style={{ width: '100px' }} />
|
||||
</ColorField>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
</AriaColorPicker>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { type CSSProperties, type ReactNode } from 'react';
|
||||
import React, { type ReactNode } from 'react';
|
||||
|
||||
import { type CSSProperties } from './styles';
|
||||
import { View } from './View';
|
||||
|
||||
type SpaceBetweenProps = {
|
||||
|
||||
@@ -62,7 +62,7 @@ export const Toggle = ({
|
||||
data-on={isOn}
|
||||
className={css(
|
||||
{
|
||||
// eslint-disable-next-line rulesdir/typography
|
||||
// eslint-disable-next-line actual/typography
|
||||
content: '" "',
|
||||
position: 'absolute',
|
||||
top: '2px',
|
||||
|
||||
@@ -27,8 +27,13 @@ export const Tooltip = ({
|
||||
const [isHovered, setIsHover] = useState(false);
|
||||
|
||||
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
const handlePointerEnter = useCallback(() => {
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setIsHover(true);
|
||||
}, triggerProps.delay ?? 300);
|
||||
@@ -41,8 +46,10 @@ export const Tooltip = ({
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
|
||||
setIsHover(false);
|
||||
}, []);
|
||||
closeTimeoutRef.current = setTimeout(() => {
|
||||
setIsHover(false);
|
||||
}, triggerProps.closeDelay ?? 0);
|
||||
}, [triggerProps.closeDelay]);
|
||||
|
||||
// Force closing the tooltip whenever the disablement state changes
|
||||
useEffect(() => {
|
||||
|
||||
@@ -23,7 +23,11 @@ export const View = forwardRef<HTMLDivElement, ViewProps>((props, ref) => {
|
||||
{...restProps}
|
||||
ref={innerRef ?? ref}
|
||||
style={nativeStyle}
|
||||
className={cx('view', className, css(style))}
|
||||
className={cx(
|
||||
'view',
|
||||
className,
|
||||
style && Object.keys(style).length > 0 ? css(style) : undefined,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -91,7 +91,7 @@ export const styles: Record<string, any> = {
|
||||
},
|
||||
shadowLarge,
|
||||
tnum: {
|
||||
// eslint-disable-next-line rulesdir/typography
|
||||
// eslint-disable-next-line actual/typography
|
||||
fontFeatureSettings: '"tnum"',
|
||||
},
|
||||
notFixed: { fontFeatureSettings: '' },
|
||||
|
||||
@@ -188,6 +188,7 @@ export const theme = {
|
||||
reportsInnerLabel: 'var(--color-reportsInnerLabel)',
|
||||
noteTagBackground: 'var(--color-noteTagBackground)',
|
||||
noteTagBackgroundHover: 'var(--color-noteTagBackgroundHover)',
|
||||
noteTagDefault: 'var(--color-noteTagDefault)',
|
||||
noteTagText: 'var(--color-noteTagText)',
|
||||
budgetOtherMonth: 'var(--color-budgetOtherMonth)',
|
||||
budgetCurrentMonth: 'var(--color-budgetCurrentMonth)',
|
||||
|
||||
@@ -18,4 +18,9 @@ protoc --plugin="protoc-gen-ts=../../node_modules/.bin/protoc-gen-ts" \
|
||||
|
||||
../../node_modules/.bin/prettier --write src/proto/*.d.ts
|
||||
|
||||
for file in src/proto/*.d.ts; do
|
||||
{ echo "/* eslint-disable @typescript-eslint/no-namespace */"; sed 's/export class/export declare class/g' "$file"; } > "${file%.d.ts}.ts"
|
||||
rm "$file"
|
||||
done
|
||||
|
||||
echo 'One more step! Find the `var global = ...` declaration in src/proto/sync_pb.js and change it to `var global = globalThis;`'
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"scripts": {
|
||||
"build:node": "tsc --p tsconfig.dist.json",
|
||||
"proto:generate": "./bin/generate-proto",
|
||||
"build": "rm -rf dist && yarn run build:node && cp src/proto/sync_pb.d.ts dist/src/proto/",
|
||||
"build": "rm -rf dist && yarn run build:node",
|
||||
"test": "vitest --globals"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -20,8 +20,10 @@
|
||||
"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.8.3",
|
||||
"vitest": "^3.1.3"
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import * as SyncPb from './proto/sync_pb';
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import './proto/sync_pb.js'; // Import for side effects
|
||||
|
||||
export {
|
||||
merkle,
|
||||
getClock,
|
||||
@@ -11,4 +13,11 @@ export {
|
||||
Timestamp,
|
||||
} from './crdt';
|
||||
|
||||
export const SyncProtoBuf = SyncPb;
|
||||
// Access global proto namespace
|
||||
export const SyncRequest = (globalThis as any).proto.SyncRequest;
|
||||
export const SyncResponse = (globalThis as any).proto.SyncResponse;
|
||||
export const Message = (globalThis as any).proto.Message;
|
||||
export const MessageEnvelope = (globalThis as any).proto.MessageEnvelope;
|
||||
export const EncryptedData = (globalThis as any).proto.EncryptedData;
|
||||
|
||||
export const SyncProtoBuf = (globalThis as any).proto;
|
||||
|
||||
@@ -157,9 +157,9 @@ proto.EncryptedData.prototype.toObject = function(opt_includeInstance) {
|
||||
*/
|
||||
proto.EncryptedData.toObject = function(includeInstance, msg) {
|
||||
var f, obj = {
|
||||
iv: msg.getIv_asB64(),
|
||||
authtag: msg.getAuthtag_asB64(),
|
||||
data: msg.getData_asB64()
|
||||
iv: msg.getIv_asB64(),
|
||||
authtag: msg.getAuthtag_asB64(),
|
||||
data: msg.getData_asB64()
|
||||
};
|
||||
|
||||
if (includeInstance) {
|
||||
@@ -419,10 +419,10 @@ proto.Message.prototype.toObject = function(opt_includeInstance) {
|
||||
*/
|
||||
proto.Message.toObject = function(includeInstance, msg) {
|
||||
var f, obj = {
|
||||
dataset: jspb.Message.getFieldWithDefault(msg, 1, ""),
|
||||
row: jspb.Message.getFieldWithDefault(msg, 2, ""),
|
||||
column: jspb.Message.getFieldWithDefault(msg, 3, ""),
|
||||
value: jspb.Message.getFieldWithDefault(msg, 4, "")
|
||||
dataset: jspb.Message.getFieldWithDefault(msg, 1, ""),
|
||||
row: jspb.Message.getFieldWithDefault(msg, 2, ""),
|
||||
column: jspb.Message.getFieldWithDefault(msg, 3, ""),
|
||||
value: jspb.Message.getFieldWithDefault(msg, 4, "")
|
||||
};
|
||||
|
||||
if (includeInstance) {
|
||||
@@ -639,9 +639,9 @@ proto.MessageEnvelope.prototype.toObject = function(opt_includeInstance) {
|
||||
*/
|
||||
proto.MessageEnvelope.toObject = function(includeInstance, msg) {
|
||||
var f, obj = {
|
||||
timestamp: jspb.Message.getFieldWithDefault(msg, 1, ""),
|
||||
isencrypted: jspb.Message.getBooleanFieldWithDefault(msg, 2, false),
|
||||
content: msg.getContent_asB64()
|
||||
timestamp: jspb.Message.getFieldWithDefault(msg, 1, ""),
|
||||
isencrypted: jspb.Message.getBooleanFieldWithDefault(msg, 2, false),
|
||||
content: msg.getContent_asB64()
|
||||
};
|
||||
|
||||
if (includeInstance) {
|
||||
@@ -860,12 +860,12 @@ proto.SyncRequest.prototype.toObject = function(opt_includeInstance) {
|
||||
*/
|
||||
proto.SyncRequest.toObject = function(includeInstance, msg) {
|
||||
var f, obj = {
|
||||
messagesList: jspb.Message.toObjectList(msg.getMessagesList(),
|
||||
messagesList: jspb.Message.toObjectList(msg.getMessagesList(),
|
||||
proto.MessageEnvelope.toObject, includeInstance),
|
||||
fileid: jspb.Message.getFieldWithDefault(msg, 2, ""),
|
||||
groupid: jspb.Message.getFieldWithDefault(msg, 3, ""),
|
||||
keyid: jspb.Message.getFieldWithDefault(msg, 5, ""),
|
||||
since: jspb.Message.getFieldWithDefault(msg, 6, "")
|
||||
fileid: jspb.Message.getFieldWithDefault(msg, 2, ""),
|
||||
groupid: jspb.Message.getFieldWithDefault(msg, 3, ""),
|
||||
keyid: jspb.Message.getFieldWithDefault(msg, 5, ""),
|
||||
since: jspb.Message.getFieldWithDefault(msg, 6, "")
|
||||
};
|
||||
|
||||
if (includeInstance) {
|
||||
@@ -1140,9 +1140,9 @@ proto.SyncResponse.prototype.toObject = function(opt_includeInstance) {
|
||||
*/
|
||||
proto.SyncResponse.toObject = function(includeInstance, msg) {
|
||||
var f, obj = {
|
||||
messagesList: jspb.Message.toObjectList(msg.getMessagesList(),
|
||||
messagesList: jspb.Message.toObjectList(msg.getMessagesList(),
|
||||
proto.MessageEnvelope.toObject, includeInstance),
|
||||
merkle: jspb.Message.getFieldWithDefault(msg, 2, "")
|
||||
merkle: jspb.Message.getFieldWithDefault(msg, 2, "")
|
||||
};
|
||||
|
||||
if (includeInstance) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-namespace */
|
||||
// package:
|
||||
// file: sync.proto
|
||||
|
||||
import * as jspb from 'google-protobuf';
|
||||
|
||||
export class EncryptedData extends jspb.Message {
|
||||
export declare class EncryptedData extends jspb.Message {
|
||||
getIv(): Uint8Array | string;
|
||||
getIv_asU8(): Uint8Array;
|
||||
getIv_asB64(): string;
|
||||
@@ -48,7 +49,7 @@ export namespace EncryptedData {
|
||||
};
|
||||
}
|
||||
|
||||
export class Message extends jspb.Message {
|
||||
export declare class Message extends jspb.Message {
|
||||
getDataset(): string;
|
||||
setDataset(value: string): void;
|
||||
|
||||
@@ -88,7 +89,7 @@ export namespace Message {
|
||||
};
|
||||
}
|
||||
|
||||
export class MessageEnvelope extends jspb.Message {
|
||||
export declare class MessageEnvelope extends jspb.Message {
|
||||
getTimestamp(): string;
|
||||
setTimestamp(value: string): void;
|
||||
|
||||
@@ -129,7 +130,7 @@ export namespace MessageEnvelope {
|
||||
};
|
||||
}
|
||||
|
||||
export class SyncRequest extends jspb.Message {
|
||||
export declare class SyncRequest extends jspb.Message {
|
||||
clearMessagesList(): void;
|
||||
getMessagesList(): Array<MessageEnvelope>;
|
||||
setMessagesList(value: Array<MessageEnvelope>): void;
|
||||
@@ -178,7 +179,7 @@ export namespace SyncRequest {
|
||||
};
|
||||
}
|
||||
|
||||
export class SyncResponse extends jspb.Message {
|
||||
export declare class SyncResponse extends jspb.Message {
|
||||
clearMessagesList(): void;
|
||||
getMessagesList(): Array<MessageEnvelope>;
|
||||
setMessagesList(value: Array<MessageEnvelope>): void;
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |