diff --git a/AGENTS.md b/AGENTS.md
index d0f5f087..4808c427 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -23,6 +23,7 @@ This project is a React-based web interface for the [Ergogen](https://github.com
- **CRITICAL:** You **MUST** run `yarn precommit` before every commit. This command formats, lints, checks for unused dependencies, and runs the entire test suite. Address all errors before proceeding. Warnings can be ignored, but should be mentioned as potential follow-up tasks.
- **Update AGENTS.md**: You **MUST** update the `AGENTS.md` file to reflect any significant changes to the application's architecture, component structure, or development workflow. This ensures the knowledge base remains current.
+- **Update CHANGELOG.md**: For every major change or PR, you **MUST** add an entry to `CHANGELOG.md`. See the Changelog section below for formatting guidelines.
## Design principles
@@ -50,6 +51,60 @@ This project is a React-based web interface for the [Ergogen](https://github.com
- The change should include related test code.
- The commit description should explain what the committed changes aim to address. Avoid repeating the same general context, and focus on information that makes it possible for the reviewer to understand the change and the reasoning behind it. Briefly call out things that will be implemented at a later stage, but avoid including too much future planning.
+## Changelog
+
+The `CHANGELOG.md` file tracks user-facing changes to the application in reverse chronological order (newest first). Each entry should be written in a blog post style that non-technical users can understand.
+
+### Changelog Entry Format
+
+**Title**: Use format "## Brief Feature Title"
+
+**Date**: Use format "_Month DD, YYYY_"
+
+**Image**: Use format: 
+
+**Opening Paragraph**: Describe the user problem or challenge that existed before the change. Make it relatable and concrete.
+
+**Middle Paragraphs**: Explain how the feature solves the problem. Focus on benefits and user experience, avoiding technical jargon. Keep the total entry under 300 words (maximum 500 words).
+
+**What changed section**: End with a bulleted list under "**What changed:**" that provides specific details:
+- Use present tense and active voice
+- Focus on user-visible changes, not implementation details
+- Keep bullets concise (one line each)
+- Highlight the most impactful changes first
+
+**Example structure:**
+```markdown
+## Feature Title
+_Month DD, YYYY-
+
+
+
+[Problem description - 1-2 sentences about what was difficult before]
+
+[Solution explanation - 2-3 sentences about how it works now and why it's better]
+
+**What changed:**
+
+- **Key feature**: Brief description of what users can now do
+- **Another feature**: How it improves the experience
+- **Supporting feature**: Additional benefit or capability
+```
+
+### When to Add Changelog Entries
+
+Add an entry for:
+- New user-facing features
+- Significant improvements to existing features
+- Bug fixes that notably impact user experience
+- Changes to workflows or user interactions
+
+Skip entries for:
+- Internal refactoring without user-visible changes
+- Dependency updates
+- Minor bug fixes
+- Documentation-only changes
+
## Knowledge base
Your task is to record important information in this file (`AGENTS.md`), which acts as a knowledge base. Analyze your chat history and `AGENTS.md` sections to propose changes, adidtions, or deletions. These changes will inform future actions in the same repository for the same user.
@@ -98,6 +153,80 @@ The application offloads long-running, computationally intensive tasks to Web Wo
Communication with the workers is managed through a standard message-passing system (`postMessage` and `onmessage`), with the main application thread and workers exchanging data as needed.
+## GitHub Integration
+
+The application supports loading Ergogen configurations directly from GitHub repositories. This feature has been extended to include automatic footprint loading.
+
+### Loading from GitHub
+
+When a user provides a GitHub repository URL (e.g., `user/repo` or `https://github.com/user/repo`), the application:
+
+1. **Fetches the configuration file**: Attempts to load `config.yaml` from standard locations:
+ - Root directory: `/config.yaml`
+ - Ergogen subdirectory: `/ergogen/config.yaml`
+ - Tries both `main` and `master` branches
+
+2. **Fetches footprints**: Recursively scans for a `footprints` folder alongside the config file:
+ - Searches for `.js` files at any depth within the `footprints` folder
+ - Constructs footprint names from the folder path and filename (e.g., `folder1/folder2/file_name`)
+ - Uses the GitHub API to traverse directories
+
+3. **Handles Git Submodules**: Checks for `.gitmodules` file in the repository root:
+ - Parses the `.gitmodules` file to find submodules within the footprints folder
+ - For each matching submodule, fetches the submodule repository recursively
+ - Loads all `.js` files from the submodule and prefixes names with the relative path
+ - Example: A submodule at `footprints/external` with `switch.js` becomes `external/switch`
+
+### Conflict Resolution
+
+When loading footprints from GitHub, the application checks for naming conflicts with existing custom footprints. If a conflict is detected:
+
+1. A `ConflictResolutionDialog` is displayed to the user with three options:
+ - **Skip**: The new footprint is not loaded
+ - **Overwrite**: The new footprint replaces the existing one
+ - **Keep Both**: Both footprints are retained; the new one gets a unique name with an incremental suffix (e.g., `footprint_1`)
+
+2. An "Apply to all conflicts" checkbox allows the user to use the same resolution strategy for all subsequent conflicts in the current load operation.
+
+### Implementation Files
+
+- **`src/utils/github.ts`**: Contains `fetchConfigFromUrl` function that returns both config and footprints, plus helper functions:
+ - `fetchFootprintsFromDirectory`: Recursive directory traversal for a single directory
+ - `fetchFootprintsFromRepo`: Recursive traversal of an entire repository (for submodules)
+ - `parseGitmodules`: Parses `.gitmodules` file to extract submodule paths and URLs
+ - `bfsForYamlFiles`: Performs breadth-first search to find YAML files in repository
+- **`src/utils/injections.ts`**: Utility functions for conflict detection (`checkForConflict`), unique name generation (`generateUniqueName`), and merging injections (`mergeInjections`)
+- **`src/molecules/ConflictResolutionDialog.tsx`**: React component for the conflict resolution UI
+- **`src/pages/Welcome.tsx`**: Orchestrates the loading process, handles conflicts sequentially, and manages dialog state
+
+### GitHub API Rate Limiting
+
+The GitHub loading functionality uses unauthenticated requests, which are subject to GitHub's rate limits:
+
+#### API Requests (api.github.com)
+
+- **Rate Limit**: 60 requests per hour for unauthenticated requests
+- **Detection**: The code checks for HTTP 403 status with `X-RateLimit-Remaining: 0` header
+- **80% Warning**: Displays warning when 80% of hourly allowance is consumed
+- **User Feedback**: When rate limit is exceeded, a clear error message is displayed: "Cannot load from GitHub right now. You've used your hourly request allowance. Please wait about an hour and try again."
+- **Graceful Handling**: The loading process continues even if rate limit is hit, just showing the error to the user
+- **Console Logging**: All rate limit headers (Limit, Remaining, Used, Reset) are logged with `[GitHub Rate Limit]` prefix
+
+#### Raw Content Requests (raw.githubusercontent.com)
+
+- **Rate Limit**: 5,000 requests per hour for unauthenticated requests
+- **Detection**: The code checks for HTTP 429 status
+- **User Feedback**: When rate limit is exceeded, displays: "You've reached your hourly request allowance for loading content from GitHub. Please wait 30 minutes and try again."
+- **No 80% Warning**: raw.githubusercontent.com doesn't provide rate limit headers, so proactive warnings are not possible
+- **Graceful Handling**: The loading process continues even if rate limit is hit, just showing the error to the user
+
+**Future Enhancement**: Implement authenticated GitHub API requests to increase API rate limit to 5,000 requests per hour. This would require:
+
+- OAuth integration or personal access token support
+- Secure token storage
+- UI for token configuration
+- Fallback to unauthenticated requests if no token is provided
+
## Future Tasks
When adding a new future task, always structure them with a unique ID, a brief title, the context, and the task, for example:
@@ -158,3 +287,17 @@ Proposed Fix: I will break down the runGeneration function into several smaller,
**Context:** After migrating the JSCAD worker to use the new `convert` API, we continue to request ASCII `stla` output and decode it into strings for compatibility. This maintains current behavior but increases payload size and requires extra decoding logic in the worker.
**Task:** Investigate switching to binary `stlb` output with typed array handling end-to-end. Update the worker and download pipeline to support binary blobs without manual header replacement, ensuring previews and downloads still function as expected.
+
+### [TASK-008] Implement Authenticated GitHub API Requests
+
+**Context:** The GitHub loading functionality currently uses unauthenticated API requests, which are limited to 60 requests per hour. For repositories with many footprints or submodules, this rate limit can be easily exceeded, preventing users from loading configurations.
+
+**Task:** Implement authenticated GitHub API requests to increase the rate limit to 5,000 requests per hour. This will involve:
+
+1. Adding OAuth integration or personal access token support
+2. Implementing secure token storage (localStorage with encryption or browser's credential storage)
+3. Creating a UI for users to configure their GitHub token (Settings page)
+4. Updating all fetch calls in `src/utils/github.ts` to include the Authorization header when a token is available
+5. Implementing fallback to unauthenticated requests if no token is provided
+6. Adding clear documentation on how to create a GitHub personal access token with appropriate permissions (public_repo scope)
+7. Handling token expiration and invalid token errors gracefully
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..215aa329
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,24 @@
+# Changelog
+
+## Load Keyboards Directly from GitHub
+_October 13, 2025_
+
+
+
+Ever wanted to share your keyboard design with a friend or try out someone else's layout? You can now load complete keyboard configurations directly from GitHub, including all the custom footprints!
+
+Previously, loading a configuration from GitHub only brought in the basic layout file. You'd have to manually recreate any custom components (like special switches or connectors) that the design depended on. This was time-consuming and error-prone, often leading to confusing errors about missing parts.
+
+Now, when you load a keyboard from GitHub, the app automatically discovers and loads all custom footprints from the repository – even those stored in separate libraries using Git submodules. If you already have a footprint with the same name, you'll get a friendly dialog asking whether to skip, overwrite, or keep both versions.
+
+The app also got smarter about finding configurations. It can now search through entire repositories to locate the right files, and it'll warn you if you're running low on your hourly request allowance so you know to take a break before trying again.
+
+**What changed:**
+
+- **Automatic footprint loading**: Custom components are now loaded alongside configurations from GitHub repositories
+- **Smart conflict resolution**: Interactive dialog lets you choose how to handle duplicate footprint names
+- **Git submodule support**: Loads footprints from external libraries referenced in the repository
+- **Intelligent file discovery**: Searches entire repositories to find configuration files in any location
+- **Usage monitoring**: Proactive warnings when approaching GitHub's request limits, with clear guidance
+- **Better feedback**: Loading progress bar now appears when fetching from GitHub
+
diff --git a/README.md b/README.md
index 10d21325..ba234af9 100644
--- a/README.md
+++ b/README.md
@@ -76,10 +76,10 @@ In addition to automatic deployments on pushes to `main`, the workflow can be tr
To manually trigger a deployment:
-1. Navigate to your repository on GitHub.
-2. Click on the **Actions** tab.
-3. In the list of workflows on the left, select **GitHub Pages**.
-4. Click the **Run workflow** button, choose the branch you want to deploy from, and confirm by clicking **Run workflow** again.
+1. Navigate to your repository on GitHub.
+2. Click on the **Actions** tab.
+3. In the list of workflows on the left, select **GitHub Pages**.
+4. Click the **Run workflow** button, choose the branch you want to deploy from, and confirm by clicking **Run workflow** again.
### Custom Domain Configuration
diff --git a/e2e/github-loading.spec.ts b/e2e/github-loading.spec.ts
new file mode 100644
index 00000000..ee713ca3
--- /dev/null
+++ b/e2e/github-loading.spec.ts
@@ -0,0 +1,249 @@
+import { test, expect } from '@playwright/test';
+import { makeShooter } from './utils/screenshots';
+
+test.describe('GitHub Loading', () => {
+ test('should load config and footprints from ceoloide/mr_useful', async ({
+ page,
+ }) => {
+ const shoot = makeShooter(page, test.info());
+
+ // Listen for console logs to verify our instrumentation
+ const logs: string[] = [];
+ const rateLimitLogs: string[] = [];
+ page.on('console', (msg) => {
+ const text = msg.text();
+ if (text.startsWith('[GitHub]')) {
+ logs.push(text);
+ console.log(text); // Also output to test log
+ }
+ if (text.startsWith('[GitHub Rate Limit]')) {
+ rateLimitLogs.push(text);
+ console.log(text); // Also output rate limit info
+ }
+ });
+
+ // Navigate to the welcome page
+ await page.goto('/new');
+ await shoot('before-github-input');
+
+ // Find the GitHub input and load button
+ const githubInput = page.getByTestId('github-input');
+ const loadButton = page.getByTestId('github-load-button');
+
+ // Enter the repository URL
+ await githubInput.fill('ceoloide/mr_useful');
+ await shoot('after-github-input-filled');
+
+ // Click the load button
+ await loadButton.click();
+ await shoot('after-load-button-clicked');
+
+ // Verify loading bar appears during GitHub loading
+ const loadingBar = page.getByTestId('loading-bar');
+ await expect(loadingBar).toBeVisible({ timeout: 5000 });
+ await shoot('loading-bar-visible');
+
+ // Wait for the config to be loaded (should navigate to home)
+ await expect(page).toHaveURL(/.*\/$/, { timeout: 30000 });
+ await shoot('after-navigation-to-home');
+
+ // Verify config editor is visible
+ await expect(page.getByTestId('config-editor')).toBeVisible({
+ timeout: 10000,
+ });
+ await shoot('config-editor-visible');
+
+ // Verify that console logs show GitHub loading activity
+ await page.waitForTimeout(2000); // Give time for all logs to appear
+
+ // Check that we have GitHub logging
+ expect(logs.length).toBeGreaterThan(0);
+ expect(logs.some((log) => log.includes('Starting fetch'))).toBe(true);
+
+ // Check for submodule loading
+ expect(
+ logs.some(
+ (log) => log.includes('.gitmodules') || log.includes('submodule')
+ )
+ ).toBe(true);
+
+ // Check for footprint loading
+ expect(logs.some((log) => log.includes('Loaded footprint'))).toBe(true);
+
+ await page.waitForTimeout(5000); // Give time for the config to load
+
+ // Open settings to check footprints
+ const settingsButton = page.getByTestId('settings-toggle-button');
+ await settingsButton.click();
+ await shoot('settings-opened');
+
+ // Wait for the injections/footprints section to be visible
+ await expect(page.getByTestId('injections-container')).toBeVisible({
+ timeout: 5000,
+ });
+ await shoot('injections-visible');
+
+ // Verify that footprints were loaded
+ // The mr_useful repo should have footprints from the submodule
+ const footprintRows = page.locator(
+ '[data-testid^="injections-container-"]'
+ );
+ const count = await footprintRows.count();
+
+ console.log(`Found ${count} footprint(s)`);
+ expect(count).toBeGreaterThan(0);
+ await shoot('footprints-loaded');
+ });
+
+ test.skip('should load config with URL parameter and footprints', async ({
+ page,
+ }) => {
+ const shoot = makeShooter(page, test.info());
+
+ // Listen for console logs
+ const logs: string[] = [];
+ const rateLimitLogs: string[] = [];
+ page.on('console', (msg) => {
+ const text = msg.text();
+ if (text.startsWith('[GitHub]')) {
+ logs.push(text);
+ console.log(text);
+ }
+ if (text.startsWith('[GitHub Rate Limit]')) {
+ rateLimitLogs.push(text);
+ console.log(text);
+ }
+ });
+
+ // Navigate directly with the github URL parameter
+ await page.goto('/?github=ceoloide/mr_useful');
+ await shoot('loaded-with-url-param');
+
+ // Verify loading bar appears during URL parameter loading
+ const loadingBar = page.getByTestId('loading-bar');
+ await expect(loadingBar).toBeVisible({ timeout: 5000 });
+ await shoot('loading-bar-visible-url-param');
+
+ // Wait for config to be loaded and editor to be visible
+ await expect(page.getByTestId('config-editor')).toBeVisible({
+ timeout: 30000,
+ });
+ await shoot('config-editor-visible-url-param');
+
+ // Wait for logs
+ await page.waitForTimeout(2000);
+
+ // Verify GitHub activity in logs
+ expect(logs.length).toBeGreaterThan(0);
+ expect(logs.some((log) => log.includes('Starting fetch'))).toBe(true);
+ expect(logs.some((log) => log.includes('Loaded footprint'))).toBe(true);
+
+ // Open settings to verify footprints
+ const settingsButton = page.getByTestId('settings-toggle-button');
+ await settingsButton.click();
+ await shoot('settings-opened-url-param');
+
+ await expect(page.getByTestId('injections-container')).toBeVisible({
+ timeout: 5000,
+ });
+
+ const footprintRows = page.locator(
+ '[data-testid^="injections-container-"]'
+ );
+ const count = await footprintRows.count();
+
+ console.log(`Found ${count} footprint(s) from URL param`);
+ expect(count).toBeGreaterThan(0);
+ await shoot('footprints-loaded-url-param');
+
+ console.log('\n=== All GitHub Logs (URL Param) ===');
+ logs.forEach((log) => console.log(log));
+ console.log('=== End GitHub Logs ===\n');
+
+ // Output rate limit logs if any
+ if (rateLimitLogs.length > 0) {
+ console.log('\n=== Rate Limit Logs (URL Param) ===');
+ rateLimitLogs.forEach((log) => console.log(log));
+ console.log('=== End Rate Limit Logs ===\n');
+ }
+ });
+
+ test.skip('should accumulate footprints from sequential loads and reset conflict dialog', async ({
+ page,
+ }) => {
+ const shoot = makeShooter(page, test.info());
+
+ // Navigate to the welcome page
+ await page.goto('/new');
+
+ // Find the GitHub input and load button
+ const githubInput = page.getByTestId('github-input');
+ const loadButton = page.getByTestId('github-load-button');
+
+ // Load first repository
+ await githubInput.fill(
+ 'https://github.com/unspecworks/gamma-omega/blob/main/original/ergogen/config.yaml'
+ );
+ await loadButton.click();
+ await shoot('first-repo-loading');
+
+ // Wait for the config to be loaded
+ await expect(page).toHaveURL(/.*\/$/, { timeout: 30000 });
+ await shoot('first-repo-loaded');
+
+ // Wait for config editor to be visible
+ await expect(page.getByTestId('config-editor')).toBeVisible({
+ timeout: 10000,
+ });
+
+ // Open settings and verify first footprint
+ let settingsButton = page.getByTestId('settings-toggle-button');
+ await settingsButton.click();
+ await shoot('settings-opened-first');
+
+ await expect(page.getByTestId('injections-container')).toBeVisible({
+ timeout: 5000,
+ });
+
+ // Check for unspecworks/pico_oneside footprint
+ const unspecworksFootprint = page.getByTestId(
+ 'injections-container-unspecworks/pico_oneside'
+ );
+ await expect(unspecworksFootprint).toBeVisible();
+ await shoot('unspecworks-footprint-present');
+
+ // Navigate back to welcome page
+ const newConfigButton = page.getByTestId('new-config-button');
+ await newConfigButton.click();
+ await expect(page).toHaveURL(/.*\/new/, { timeout: 5000 });
+ await shoot('back-to-welcome');
+
+ // Load second repository
+ await githubInput.fill('ceoloide/mr_useful');
+ await loadButton.click();
+ await shoot('second-repo-loading');
+
+ // Wait for the config to be loaded
+ await expect(page).toHaveURL(/.*\/$/, { timeout: 30000 });
+ await shoot('second-repo-loaded');
+
+ // Open settings and verify both footprints are present
+ settingsButton = page.getByTestId('settings-toggle-button');
+ await settingsButton.click();
+ await shoot('settings-opened-second');
+
+ await expect(page.getByTestId('injections-container')).toBeVisible({
+ timeout: 5000,
+ });
+
+ // Both footprints should be present
+ const logoFootprint = page.getByTestId(
+ 'injections-container-logo_mr_useful'
+ );
+ await expect(logoFootprint).toBeVisible();
+ await expect(unspecworksFootprint).toBeVisible();
+ await shoot('both-footprints-present');
+
+ console.log('Sequential loading test completed successfully');
+ });
+});
diff --git a/playwright.config.ts b/playwright.config.ts
index 5f6e56f9..a61b6c03 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -4,7 +4,7 @@ export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
- retries: process.env.CI ? 2 : 0,
+ retries: 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? 'list' : 'html',
use: {
diff --git a/public/images/changelog/2025-10-13.png b/public/images/changelog/2025-10-13.png
new file mode 100644
index 00000000..2d0389bb
Binary files /dev/null and b/public/images/changelog/2025-10-13.png differ
diff --git a/public/images/changelog/placeholder.png b/public/images/changelog/placeholder.png
new file mode 100644
index 00000000..2e3a4dc6
Binary files /dev/null and b/public/images/changelog/placeholder.png differ
diff --git a/src/context/ConfigContext.test.tsx b/src/context/ConfigContext.test.tsx
index 5fc19b84..cb4dc480 100644
--- a/src/context/ConfigContext.test.tsx
+++ b/src/context/ConfigContext.test.tsx
@@ -69,11 +69,22 @@ describe('ConfigContextProvider', () => {
});
it('should fetch config from github url parameter and update the config', async () => {
- const fetchSpy = jest
- .spyOn(window, 'fetch')
- .mockImplementation(() =>
- Promise.resolve(new Response(mockConfig, { status: 200 }))
- );
+ const fetchSpy = jest.spyOn(window, 'fetch').mockImplementation((url) => {
+ if (
+ url ===
+ 'https://raw.githubusercontent.com/ceoloide/corney-island/main/ergogen/config.yaml'
+ ) {
+ return Promise.resolve(new Response(mockConfig, { status: 200 }));
+ }
+ if (
+ typeof url === 'string' &&
+ url.includes('api.github.com/repos') &&
+ url.includes('footprints')
+ ) {
+ return Promise.resolve(new Response('[]', { status: 404 }));
+ }
+ return Promise.resolve(new Response('', { status: 404 }));
+ });
// Set the URL for the test
window.history.pushState(
@@ -102,11 +113,22 @@ describe('ConfigContextProvider', () => {
});
it('should fetch config from github url parameter without protocol and update the config', async () => {
- const fetchSpy = jest
- .spyOn(window, 'fetch')
- .mockImplementation(() =>
- Promise.resolve(new Response(mockConfig, { status: 200 }))
- );
+ const fetchSpy = jest.spyOn(window, 'fetch').mockImplementation((url) => {
+ if (
+ url ===
+ 'https://raw.githubusercontent.com/ceoloide/corney-island/main/ergogen/config.yaml'
+ ) {
+ return Promise.resolve(new Response(mockConfig, { status: 200 }));
+ }
+ if (
+ typeof url === 'string' &&
+ url.includes('api.github.com/repos') &&
+ url.includes('footprints')
+ ) {
+ return Promise.resolve(new Response('[]', { status: 404 }));
+ }
+ return Promise.resolve(new Response('', { status: 404 }));
+ });
// Set the URL for the test
window.history.pushState(
@@ -134,6 +156,77 @@ describe('ConfigContextProvider', () => {
fetchSpy.mockRestore();
});
+ it('should load footprints from github url parameter and merge them', async () => {
+ const fetchSpy = jest.spyOn(window, 'fetch').mockImplementation((url) => {
+ if (
+ url ===
+ 'https://raw.githubusercontent.com/ceoloide/test-repo/main/config.yaml'
+ ) {
+ return Promise.resolve(new Response(mockConfig, { status: 200 }));
+ }
+ if (
+ typeof url === 'string' &&
+ url.includes('api.github.com/repos') &&
+ url.includes('footprints')
+ ) {
+ // Return a footprint
+ return Promise.resolve(
+ new Response(
+ JSON.stringify([
+ {
+ type: 'file',
+ name: 'test_footprint.js',
+ download_url:
+ 'https://raw.githubusercontent.com/ceoloide/test-repo/main/footprints/test_footprint.js',
+ },
+ ]),
+ { status: 200 }
+ )
+ );
+ }
+ if (
+ url ===
+ 'https://raw.githubusercontent.com/ceoloide/test-repo/main/footprints/test_footprint.js'
+ ) {
+ return Promise.resolve(
+ new Response('module.exports = {}', { status: 200 })
+ );
+ }
+ if (typeof url === 'string' && url.includes('.gitmodules')) {
+ return Promise.resolve(new Response('', { status: 404 }));
+ }
+ return Promise.resolve(new Response('', { status: 404 }));
+ });
+
+ // Set the URL for the test
+ window.history.pushState({}, 'Test page', '/?github=ceoloide/test-repo');
+
+ const setConfigInputMock = jest.fn();
+
+ render(
+
+
+
+ );
+
+ // Wait for config to be set
+ await waitFor(() => {
+ expect(setConfigInputMock).toHaveBeenCalledWith(mockConfig);
+ });
+
+ // Verify that the footprint was loaded by checking localStorage
+ await waitFor(() => {
+ const injections = localStorage.getItem('ergogen:injection');
+ expect(injections).toBeTruthy();
+ const parsed = JSON.parse(injections as string);
+ expect(parsed).toEqual([
+ ['footprint', 'test_footprint', 'module.exports = {}'],
+ ]);
+ });
+
+ fetchSpy.mockRestore();
+ });
+
describe('STL Conversion', () => {
it('should batch convert JSCAD to STL when stlPreview is true', async () => {
localStorage.setItem('ergogen:config:stlPreview', 'true');
diff --git a/src/context/ConfigContext.tsx b/src/context/ConfigContext.tsx
index 3729868c..59b488ba 100644
--- a/src/context/ConfigContext.tsx
+++ b/src/context/ConfigContext.tsx
@@ -158,6 +158,7 @@ type ContextProps = {
setStlPreview: Dispatch>;
experiment: string | null;
isGenerating: boolean;
+ setIsGenerating: Dispatch>;
isJscadConverting: boolean;
};
@@ -567,13 +568,68 @@ const ConfigContextProvider = ({
const queryParameters = new URLSearchParams(window.location.search);
const githubUrl = queryParameters.get('github');
if (githubUrl) {
+ console.log('[ConfigContext] Loading from URL parameter:', githubUrl);
fetchConfigFromUrl(githubUrl)
- .then((data) => {
- setConfigInput(data);
- generateNow(data, injectionInput, { pointsonly: false });
+ .then(async (result) => {
+ console.log('[ConfigContext] Fetch result:', {
+ configLength: result.config.length,
+ footprintsCount: result.footprints.length,
+ configPath: result.configPath,
+ rateLimitWarning: result.rateLimitWarning,
+ });
+ console.log(
+ '[ConfigContext] Footprints:',
+ result.footprints.map((f) => f.name)
+ );
+
+ // Show rate limit warning if present
+ if (result.rateLimitWarning) {
+ setError(result.rateLimitWarning);
+ }
+
+ try {
+ // Import mergeInjections to handle footprints
+ const { mergeInjections } = await import('../utils/injections');
+
+ console.log(
+ '[ConfigContext] Current injectionInput before merge:',
+ injectionInput
+ );
+
+ // Merge footprints with existing injections using 'overwrite' strategy
+ // This ensures GitHub footprints take precedence when loading from URL
+ const mergedInjections = mergeInjections(
+ result.footprints,
+ injectionInput,
+ 'overwrite'
+ );
+
+ console.log(
+ '[ConfigContext] Merged injections count:',
+ mergedInjections.length
+ );
+ console.log(
+ '[ConfigContext] Merged injections:',
+ mergedInjections.map((inj) => inj[1])
+ );
+
+ setInjectionInput(mergedInjections);
+ setConfigInput(result.config);
+ generateNow(result.config, mergedInjections, { pointsonly: false });
+ } catch (error) {
+ // If footprint processing fails, don't load the config
+ console.error(
+ '[ConfigContext] Error processing footprints:',
+ error
+ );
+ throw new Error(
+ `Failed to process footprints: ${error instanceof Error ? error.message : 'Unknown error'}`
+ );
+ }
})
.catch((e) => {
- setError(`Failed to fetch config from GitHub: ${e.message}`);
+ console.error('[ConfigContext] Failed to load from GitHub:', e);
+ setError(`Failed to load from GitHub: ${e.message}`);
});
} else if (configInput) {
generateNow(configInput, injectionInput, { pointsonly: false });
@@ -631,6 +687,7 @@ const ConfigContextProvider = ({
setStlPreview,
experiment,
isGenerating,
+ setIsGenerating,
isJscadConverting,
}),
[
@@ -666,6 +723,7 @@ const ConfigContextProvider = ({
setStlPreview,
experiment,
isGenerating,
+ setIsGenerating,
isJscadConverting,
]
);
diff --git a/src/molecules/ConflictResolutionDialog.test.tsx b/src/molecules/ConflictResolutionDialog.test.tsx
new file mode 100644
index 00000000..8e5c29f7
--- /dev/null
+++ b/src/molecules/ConflictResolutionDialog.test.tsx
@@ -0,0 +1,145 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import ConflictResolutionDialog from './ConflictResolutionDialog';
+
+describe('ConflictResolutionDialog', () => {
+ const mockOnResolve = jest.fn();
+ const mockOnCancel = jest.fn();
+ const footprintName = 'test/footprint';
+
+ beforeEach(() => {
+ mockOnResolve.mockClear();
+ mockOnCancel.mockClear();
+ });
+
+ it('renders with the correct footprint name', () => {
+ // Arrange & Act
+ render(
+
+ );
+
+ // Assert
+ expect(screen.getByText('Injection Conflict')).toBeInTheDocument();
+ expect(screen.getByText(footprintName)).toBeInTheDocument();
+ });
+
+ it('calls onResolve with "skip" when Skip button is clicked', () => {
+ // Arrange
+ render(
+
+ );
+
+ // Act
+ fireEvent.click(screen.getByTestId('conflict-dialog-skip'));
+
+ // Assert
+ expect(mockOnResolve).toHaveBeenCalledWith('skip', false);
+ });
+
+ it('calls onResolve with "overwrite" when Overwrite button is clicked', () => {
+ // Arrange
+ render(
+
+ );
+
+ // Act
+ fireEvent.click(screen.getByTestId('conflict-dialog-overwrite'));
+
+ // Assert
+ expect(mockOnResolve).toHaveBeenCalledWith('overwrite', false);
+ });
+
+ it('calls onResolve with "keep-both" when Keep Both button is clicked', () => {
+ // Arrange
+ render(
+
+ );
+
+ // Act
+ fireEvent.click(screen.getByTestId('conflict-dialog-keep-both'));
+
+ // Assert
+ expect(mockOnResolve).toHaveBeenCalledWith('keep-both', false);
+ });
+
+ it('calls onResolve with applyToAll=true when checkbox is checked', () => {
+ // Arrange
+ render(
+
+ );
+
+ // Act
+ const checkbox = screen.getByTestId('conflict-dialog-apply-to-all');
+ fireEvent.click(checkbox);
+ fireEvent.click(screen.getByTestId('conflict-dialog-skip'));
+
+ // Assert
+ expect(mockOnResolve).toHaveBeenCalledWith('skip', true);
+ });
+
+ it('calls onCancel when Cancel button is clicked', () => {
+ // Arrange
+ render(
+
+ );
+
+ // Act
+ fireEvent.click(screen.getByTestId('conflict-dialog-cancel'));
+
+ // Assert
+ expect(mockOnCancel).toHaveBeenCalled();
+ });
+
+ it('has accessible labels for all interactive elements', () => {
+ // Arrange
+ render(
+
+ );
+
+ // Assert
+ expect(
+ screen.getByLabelText('Apply this choice to all conflicts')
+ ).toBeInTheDocument();
+ expect(screen.getByLabelText('Skip this injection')).toBeInTheDocument();
+ expect(
+ screen.getByLabelText('Overwrite existing injection')
+ ).toBeInTheDocument();
+ expect(screen.getByLabelText('Keep both injections')).toBeInTheDocument();
+ expect(screen.getByLabelText('Cancel loading')).toBeInTheDocument();
+ });
+});
diff --git a/src/molecules/ConflictResolutionDialog.tsx b/src/molecules/ConflictResolutionDialog.tsx
new file mode 100644
index 00000000..ddc97892
--- /dev/null
+++ b/src/molecules/ConflictResolutionDialog.tsx
@@ -0,0 +1,163 @@
+import React, { useState } from 'react';
+import styled from 'styled-components';
+import { theme } from '../theme/theme';
+import Button from '../atoms/Button';
+
+/**
+ * Props for the ConflictResolutionDialog component.
+ */
+export type ConflictResolutionDialogProps = {
+ footprintName: string;
+ onResolve: (
+ action: 'skip' | 'overwrite' | 'keep-both',
+ applyToAll: boolean
+ ) => void;
+ onCancel: () => void;
+ 'data-testid'?: string;
+};
+
+/**
+ * A dialog component that prompts the user to resolve injection name conflicts.
+ * Provides options to skip, overwrite, or keep both injections.
+ */
+const ConflictResolutionDialog: React.FC = ({
+ footprintName,
+ onResolve,
+ onCancel,
+ 'data-testid': dataTestId,
+}) => {
+ const [applyToAll, setApplyToAll] = useState(false);
+
+ return (
+
+
+ Injection Conflict
+
+ An injection with the name {footprintName} already
+ exists. How would you like to resolve this conflict?
+
+
+ setApplyToAll(e.target.checked)}
+ data-testid={dataTestId && `${dataTestId}-apply-to-all`}
+ aria-label="Apply this choice to all conflicts"
+ />
+
+
+
+
+
+
+
+
+ Cancel
+
+
+
+ );
+};
+
+const Overlay = styled.div`
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+`;
+
+const DialogBox = styled.div`
+ background-color: ${theme.colors.backgroundLight};
+ border: 1px solid ${theme.colors.border};
+ border-radius: 8px;
+ padding: 2rem;
+ max-width: 500px;
+ width: 90%;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
+`;
+
+const Title = styled.h2`
+ margin: 0 0 1rem 0;
+ font-size: ${theme.fontSizes.h3};
+ color: ${theme.colors.text};
+`;
+
+const Message = styled.p`
+ margin: 0 0 1.5rem 0;
+ font-size: ${theme.fontSizes.base};
+ color: ${theme.colors.textDark};
+ line-height: 1.5;
+
+ strong {
+ color: ${theme.colors.accent};
+ }
+`;
+
+const CheckboxContainer = styled.div`
+ display: flex;
+ align-items: center;
+ margin-bottom: 1.5rem;
+ gap: 0.5rem;
+
+ input[type='checkbox'] {
+ cursor: pointer;
+ }
+
+ label {
+ cursor: pointer;
+ font-size: ${theme.fontSizes.base};
+ color: ${theme.colors.textDark};
+ }
+`;
+
+const ButtonGroup = styled.div`
+ display: flex;
+ gap: 0.75rem;
+ justify-content: space-between;
+ margin-bottom: 1rem;
+`;
+
+const CancelButton = styled(Button)`
+ width: 100%;
+ background-color: ${theme.colors.backgroundLighter};
+ color: ${theme.colors.textDark};
+
+ &:hover {
+ background-color: ${theme.colors.buttonHover};
+ }
+`;
+
+export default ConflictResolutionDialog;
diff --git a/src/pages/Welcome.tsx b/src/pages/Welcome.tsx
index 22be2e24..156e3f4f 100644
--- a/src/pages/Welcome.tsx
+++ b/src/pages/Welcome.tsx
@@ -5,9 +5,15 @@ import { theme } from '../theme/theme';
import { useConfigContext } from '../context/ConfigContext';
import { exampleOptions, ConfigOption } from '../examples';
import EmptyYAML from '../examples/empty_yaml';
-import { fetchConfigFromUrl } from '../utils/github';
+import { fetchConfigFromUrl, GitHubFootprint } from '../utils/github';
+import {
+ checkForConflict,
+ mergeInjections,
+ ConflictResolution,
+} from '../utils/injections';
import Button from '../atoms/Button';
import Input from '../atoms/Input';
+import ConflictResolutionDialog from '../molecules/ConflictResolutionDialog';
// Styled Components
const WelcomePageWrapper = styled.div`
@@ -138,6 +144,11 @@ const Welcome = () => {
const [githubInput, setGithubInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [shouldNavigate, setShouldNavigate] = useState(false);
+ const [pendingFootprints, setPendingFootprints] = useState(
+ []
+ );
+ const [currentConflict, setCurrentConflict] = useState(null);
+ const [pendingConfig, setPendingConfig] = useState(null);
// Navigate to home when config has been set
useEffect(() => {
@@ -159,31 +170,182 @@ const Welcome = () => {
}
};
+ const processFootprints = async (
+ footprints: GitHubFootprint[],
+ config: string,
+ resolution: ConflictResolution | null = null,
+ currentInjections?: string[][]
+ ): Promise => {
+ if (!configContext) {
+ throw new Error('Configuration context not available');
+ }
+
+ // Use provided injections or fall back to context
+ const injectionsToUse = currentInjections || configContext.injectionInput;
+
+ if (footprints.length === 0) {
+ // No footprints to process, just load the config
+ configContext.setInjectionInput(injectionsToUse);
+ configContext.setConfigInput(config);
+ await configContext.generateNow(config, injectionsToUse, {
+ pointsonly: false,
+ });
+ setShouldNavigate(true);
+ return;
+ }
+
+ const currentFootprint = footprints[0];
+ const remainingFootprints = footprints.slice(1);
+
+ // Check for conflict using the current injections state
+ const conflictCheck = checkForConflict(
+ currentFootprint.name,
+ injectionsToUse
+ );
+
+ if (conflictCheck.hasConflict && !resolution) {
+ // Show dialog and pause processing
+ setCurrentConflict(currentFootprint.name);
+ setPendingFootprints(footprints);
+ setPendingConfig(config);
+ return;
+ }
+
+ // Determine resolution to use
+ const resolutionToUse = resolution || 'skip';
+
+ // Merge this footprint with the current injections state
+ const mergedInjections = mergeInjections(
+ [currentFootprint],
+ injectionsToUse,
+ resolutionToUse
+ );
+
+ // Process remaining footprints with the updated injections
+ if (remainingFootprints.length > 0) {
+ await processFootprints(
+ remainingFootprints,
+ config,
+ resolution,
+ mergedInjections
+ );
+ } else {
+ // All footprints processed, update context and load the config
+ configContext.setInjectionInput(mergedInjections);
+ configContext.setConfigInput(config);
+ await configContext.generateNow(config, mergedInjections, {
+ pointsonly: false,
+ });
+ setShouldNavigate(true);
+ }
+ };
+
+ const handleConflictResolution = async (
+ action: ConflictResolution,
+ applyToAllConflicts: boolean
+ ) => {
+ if (!configContext || !pendingFootprints || !pendingConfig) return;
+
+ setCurrentConflict(null);
+
+ // Process the current footprint with the chosen action
+ const currentFootprint = pendingFootprints[0];
+ const remainingFootprints = pendingFootprints.slice(1);
+
+ // Merge with current injections state
+ const mergedInjections = mergeInjections(
+ [currentFootprint],
+ configContext.injectionInput,
+ action
+ );
+
+ // Resume processing remaining footprints with the updated injections
+ if (remainingFootprints.length > 0) {
+ await processFootprints(
+ remainingFootprints,
+ pendingConfig,
+ applyToAllConflicts ? action : null,
+ mergedInjections
+ );
+ } else {
+ // All footprints processed, update context and load the config
+ configContext.setInjectionInput(mergedInjections);
+ configContext.setConfigInput(pendingConfig);
+ await configContext.generateNow(pendingConfig, mergedInjections, {
+ pointsonly: false,
+ });
+ setShouldNavigate(true);
+
+ // Clean up state only after all footprints are processed
+ setPendingFootprints([]);
+ setPendingConfig(null);
+ }
+ };
+
+ const handleConflictCancel = () => {
+ setCurrentConflict(null);
+ setPendingFootprints([]);
+ setPendingConfig(null);
+ setIsLoading(false);
+ // Show error message that loading was cancelled
+ if (configContext) {
+ configContext.setError('Loading cancelled by user');
+ }
+ };
+
const handleGitHub = () => {
if (!githubInput || !configContext) return;
- const { setError, clearError } = configContext;
+ const { setError, clearError, setIsGenerating } = configContext;
setIsLoading(true);
+ setIsGenerating(true); // Show progress bar during GitHub loading
clearError();
+
+ // Reset any pending conflict resolution state from previous loads
+ setCurrentConflict(null);
+ setPendingFootprints([]);
+ setPendingConfig(null);
+
fetchConfigFromUrl(githubInput)
- .then(async (data) => {
+ .then(async (result) => {
if (configContext) {
- configContext.setConfigInput(data);
- await configContext.generateNow(data, configContext.injectionInput, {
- pointsonly: false,
- });
- setShouldNavigate(true);
+ // Show rate limit warning if present
+ if (result.rateLimitWarning) {
+ setError(result.rateLimitWarning);
+ }
+
+ try {
+ // Process footprints with conflict resolution
+ await processFootprints(result.footprints, result.config);
+ } catch (error) {
+ // If footprint processing fails, don't load the config
+ throw new Error(
+ `Failed to process footprints: ${error instanceof Error ? error.message : 'Unknown error'}`
+ );
+ }
}
})
.catch((e) => {
- setError(`Failed to fetch config from GitHub: ${e.message}`);
+ setError(`Failed to load from GitHub: ${e.message}`);
+ // Ensure we reset loading state and don't navigate
+ setIsLoading(false);
+ setIsGenerating(false);
})
.finally(() => {
setIsLoading(false);
+ // Note: isGenerating will be reset by generateNow or needs explicit reset on error
});
};
return (
+ {currentConflict && (
+
+ )}
diff --git a/src/utils/github.test.ts b/src/utils/github.test.ts
new file mode 100644
index 00000000..8a7196f5
--- /dev/null
+++ b/src/utils/github.test.ts
@@ -0,0 +1,235 @@
+import { fetchConfigFromUrl } from './github';
+
+// Mock fetch globally
+global.fetch = jest.fn();
+
+describe('github utilities', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('fetchConfigFromUrl with submodules', () => {
+ it('should fetch footprints from submodules when .gitmodules exists', async () => {
+ // Arrange
+ const mockFetch = global.fetch as jest.MockedFunction;
+
+ // Mock config.yaml fetch from root (404)
+ mockFetch.mockImplementationOnce(() =>
+ Promise.resolve(
+ new Response('', { status: 404 }) as unknown as Response
+ )
+ );
+
+ // Mock config.yaml fetch from ergogen folder (success)
+ mockFetch.mockImplementationOnce(() =>
+ Promise.resolve(
+ new Response('points: {}', { status: 200 }) as unknown as Response
+ )
+ );
+
+ // Mock footprints directory (empty)
+ mockFetch.mockImplementationOnce(() =>
+ Promise.resolve(
+ new Response('[]', { status: 404 }) as unknown as Response
+ )
+ );
+
+ // Mock .gitmodules fetch
+ mockFetch.mockImplementationOnce(() =>
+ Promise.resolve(
+ new Response(
+ '[submodule "ergogen/footprints/ceoloide"]\n' +
+ '\tpath = ergogen/footprints/ceoloide\n' +
+ '\turl = https://github.com/ceoloide/ergogen-footprints.git',
+ { status: 200 }
+ ) as unknown as Response
+ )
+ );
+
+ // Mock submodule repo contents (main branch)
+ mockFetch.mockImplementationOnce(() =>
+ Promise.resolve(
+ new Response(
+ JSON.stringify([
+ {
+ type: 'file',
+ name: 'test_footprint.js',
+ download_url:
+ 'https://raw.githubusercontent.com/ceoloide/ergogen-footprints/main/test_footprint.js',
+ },
+ ]),
+ { status: 200 }
+ ) as unknown as Response
+ )
+ );
+
+ // Mock footprint file content
+ mockFetch.mockImplementationOnce(() =>
+ Promise.resolve(
+ new Response('module.exports = {}', {
+ status: 200,
+ }) as unknown as Response
+ )
+ );
+
+ // Act
+ const result = await fetchConfigFromUrl('ceoloide/test-repo');
+
+ // Assert
+ expect(result.config).toBe('points: {}');
+ expect(result.footprints).toHaveLength(1);
+ expect(result.footprints[0].name).toBe('ceoloide/test_footprint');
+ expect(result.footprints[0].content).toBe('module.exports = {}');
+ });
+
+ it('should handle submodules with nested folders', async () => {
+ // Arrange
+ const mockFetch = global.fetch as jest.MockedFunction;
+
+ // Mock config.yaml fetch
+ mockFetch.mockImplementationOnce(() =>
+ Promise.resolve(
+ new Response('points: {}', { status: 200 }) as unknown as Response
+ )
+ );
+
+ // Mock footprints directory (empty)
+ mockFetch.mockImplementationOnce(() =>
+ Promise.resolve(
+ new Response('[]', { status: 404 }) as unknown as Response
+ )
+ );
+
+ // Mock .gitmodules fetch
+ mockFetch.mockImplementationOnce(() =>
+ Promise.resolve(
+ new Response(
+ '[submodule "footprints/external"]\n' +
+ '\tpath = footprints/external\n' +
+ '\turl = https://github.com/test/footprints.git',
+ { status: 200 }
+ ) as unknown as Response
+ )
+ );
+
+ // Mock submodule repo root contents
+ mockFetch.mockImplementationOnce(() =>
+ Promise.resolve(
+ new Response(
+ JSON.stringify([
+ {
+ type: 'dir',
+ name: 'switches',
+ url: 'https://api.github.com/repos/test/footprints/contents/switches',
+ },
+ ]),
+ { status: 200 }
+ ) as unknown as Response
+ )
+ );
+
+ // Mock submodule subdirectory contents
+ mockFetch.mockImplementationOnce(() =>
+ Promise.resolve(
+ new Response(
+ JSON.stringify([
+ {
+ type: 'file',
+ name: 'mx.js',
+ download_url:
+ 'https://raw.githubusercontent.com/test/footprints/main/switches/mx.js',
+ },
+ ]),
+ { status: 200 }
+ ) as unknown as Response
+ )
+ );
+
+ // Mock footprint file content
+ mockFetch.mockImplementationOnce(() =>
+ Promise.resolve(
+ new Response('module.exports = {}', {
+ status: 200,
+ }) as unknown as Response
+ )
+ );
+
+ // Act
+ const result = await fetchConfigFromUrl('test/repo');
+
+ // Assert
+ expect(result.footprints).toHaveLength(1);
+ expect(result.footprints[0].name).toBe('external/switches/mx');
+ });
+
+ it('should skip submodules that are not in the footprints folder', async () => {
+ // Arrange
+ const mockFetch = global.fetch as jest.MockedFunction;
+
+ // Mock config.yaml fetch
+ mockFetch.mockImplementationOnce(() =>
+ Promise.resolve(
+ new Response('points: {}', { status: 200 }) as unknown as Response
+ )
+ );
+
+ // Mock footprints directory (empty)
+ mockFetch.mockImplementationOnce(() =>
+ Promise.resolve(
+ new Response('[]', { status: 404 }) as unknown as Response
+ )
+ );
+
+ // Mock .gitmodules fetch with non-footprint submodule
+ mockFetch.mockImplementationOnce(() =>
+ Promise.resolve(
+ new Response(
+ '[submodule "docs"]\n' +
+ '\tpath = docs\n' +
+ '\turl = https://github.com/test/docs.git',
+ { status: 200 }
+ ) as unknown as Response
+ )
+ );
+
+ // Act
+ const result = await fetchConfigFromUrl('test/repo');
+
+ // Assert
+ expect(result.footprints).toHaveLength(0);
+ });
+
+ it('should handle missing .gitmodules gracefully', async () => {
+ // Arrange
+ const mockFetch = global.fetch as jest.MockedFunction;
+
+ // Mock config.yaml fetch
+ mockFetch.mockImplementationOnce(() =>
+ Promise.resolve(
+ new Response('points: {}', { status: 200 }) as unknown as Response
+ )
+ );
+
+ // Mock footprints directory (empty)
+ mockFetch.mockImplementationOnce(() =>
+ Promise.resolve(
+ new Response('[]', { status: 404 }) as unknown as Response
+ )
+ );
+
+ // Mock .gitmodules fetch (404)
+ mockFetch.mockImplementationOnce(() =>
+ Promise.resolve(
+ new Response('', { status: 404 }) as unknown as Response
+ )
+ );
+
+ // Act
+ const result = await fetchConfigFromUrl('test/repo');
+
+ // Assert
+ expect(result.footprints).toHaveLength(0);
+ expect(result.config).toBe('points: {}');
+ });
+ });
+});
diff --git a/src/utils/github.ts b/src/utils/github.ts
index 8d58b9c9..650a6470 100644
--- a/src/utils/github.ts
+++ b/src/utils/github.ts
@@ -10,17 +10,330 @@ const getRawUrl = (url: string) => {
return rawUrl;
};
+/**
+ * Checks GitHub API rate limit headers and logs usage information.
+ * Returns an error object if rate limit is exceeded or threshold is crossed.
+ * Also handles raw.githubusercontent.com rate limits (HTTP 429).
+ * @param {Response} response - The fetch response object.
+ * @param {string} url - The URL being fetched (to determine if it's API or raw content).
+ * @returns {{isLimitExceeded: boolean, error: string | null}} Rate limit status.
+ */
+const checkRateLimit = (
+ response: Response,
+ url: string
+): { isLimitExceeded: boolean; error: string | null } => {
+ // Check if this is a raw content URL (raw.githubusercontent.com)
+ const isRawContent = url.includes('raw.githubusercontent.com');
+
+ if (isRawContent) {
+ // raw.githubusercontent.com uses HTTP 429 for rate limiting
+ if (response.status === 429) {
+ console.warn(
+ '[GitHub] Raw content rate limit exceeded (429). Please wait 30 minutes and try again.'
+ );
+ return {
+ isLimitExceeded: true,
+ error:
+ "You've reached your hourly request allowance for loading content from GitHub. Please wait 30 minutes and try again.",
+ };
+ }
+ // No rate limit headers on raw.githubusercontent.com, so we can't warn at 80%
+ return { isLimitExceeded: false, error: null };
+ }
+
+ // For api.github.com requests, check rate limit headers
+ const limit = response.headers.get('X-RateLimit-Limit') || 'unknown';
+ const remaining = response.headers.get('X-RateLimit-Remaining') || 'unknown';
+ const used = response.headers.get('X-RateLimit-Used') || 'unknown';
+ const reset = response.headers.get('X-RateLimit-Reset') || 'unknown';
+
+ // Log rate limit info
+ console.log(
+ `[GitHub Rate Limit] Limit: ${limit}, Remaining: ${remaining}, Used: ${used}, Reset: ${reset}`
+ );
+
+ // Check if rate limit is exceeded
+ if (response.status === 403 && remaining === '0') {
+ console.warn(
+ '[GitHub] Rate limit exceeded. Please wait and try again in about an hour.'
+ );
+ return {
+ isLimitExceeded: true,
+ error:
+ "Cannot load from GitHub right now. You've used your hourly request allowance. Please wait about an hour and try again.",
+ };
+ }
+
+ // Check if approaching rate limit (80% threshold)
+ if (remaining !== 'unknown' && limit !== 'unknown') {
+ const limitNum = parseInt(limit);
+ const remainingNum = parseInt(remaining);
+ const percentUsed = ((limitNum - remainingNum) / limitNum) * 100;
+
+ if (percentUsed >= 80 && remainingNum > 0) {
+ console.warn(
+ `[GitHub] Approaching rate limit: ${percentUsed.toFixed(1)}% used`
+ );
+ return {
+ isLimitExceeded: false,
+ error:
+ "Loading from GitHub may become unavailable soon. You've used most of your hourly request allowance. This will reset within an hour.",
+ };
+ }
+ }
+
+ return { isLimitExceeded: false, error: null };
+};
+
+/**
+ * Represents a footprint loaded from GitHub.
+ */
+export type GitHubFootprint = {
+ name: string;
+ content: string;
+};
+
+/**
+ * Represents the result of loading from GitHub, including config and footprints.
+ */
+export type GitHubLoadResult = {
+ config: string;
+ footprints: GitHubFootprint[];
+ configPath: string;
+ rateLimitWarning?: string;
+};
+
+/**
+ * Parses .gitmodules content to extract submodule information.
+ * @param {string} content - The content of the .gitmodules file.
+ * @returns {Array<{path: string, url: string}>} Array of submodule objects.
+ */
+const parseGitmodules = (
+ content: string
+): Array<{ path: string; url: string }> => {
+ console.log('[GitHub] Parsing .gitmodules file');
+ const submodules: Array<{ path: string; url: string }> = [];
+ const lines = content.split('\n');
+ let currentSubmodule: { path?: string; url?: string } = {};
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (trimmed.startsWith('[submodule')) {
+ // Start of a new submodule
+ if (currentSubmodule.path && currentSubmodule.url) {
+ submodules.push({
+ path: currentSubmodule.path,
+ url: currentSubmodule.url,
+ });
+ }
+ currentSubmodule = {};
+ } else if (trimmed.startsWith('path =')) {
+ currentSubmodule.path = trimmed.substring(7).trim();
+ } else if (trimmed.startsWith('url =')) {
+ currentSubmodule.url = trimmed.substring(6).trim();
+ }
+ }
+
+ // Add the last submodule if exists
+ if (currentSubmodule.path && currentSubmodule.url) {
+ submodules.push({
+ path: currentSubmodule.path,
+ url: currentSubmodule.url,
+ });
+ }
+
+ console.log(`[GitHub] Found ${submodules.length} submodules:`, submodules);
+ return submodules;
+};
+
+/**
+ * Recursively fetches all .js files from a GitHub repository.
+ * @param {string} owner - The repository owner.
+ * @param {string} repo - The repository name.
+ * @param {string} branch - The branch to fetch from.
+ * @param {string} basePath - The base path for constructing footprint names.
+ * @param {{warning: string | null}} rateLimitTracker - Mutable object to track rate limit warnings.
+ * @returns {Promise} A promise that resolves with the list of footprints.
+ */
+const fetchFootprintsFromRepo = async (
+ owner: string,
+ repo: string,
+ branch: string,
+ basePath: string = '',
+ rateLimitTracker: { warning: string | null } = { warning: null }
+): Promise => {
+ console.log(
+ `[GitHub] Fetching footprints from repo ${owner}/${repo} (branch: ${branch}, path: ${basePath || 'root'})`
+ );
+ const footprints: GitHubFootprint[] = [];
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${basePath ? basePath : ''}?ref=${branch}`;
+
+ try {
+ const response = await fetch(apiUrl);
+
+ // Check rate limit
+ const rateLimitCheck = checkRateLimit(response, apiUrl);
+ if (rateLimitCheck.error && !rateLimitTracker.warning) {
+ rateLimitTracker.warning = rateLimitCheck.error;
+ }
+ if (rateLimitCheck.isLimitExceeded) {
+ throw new Error(rateLimitCheck.error || 'Rate limit exceeded');
+ }
+
+ if (!response.ok) {
+ console.log(
+ `[GitHub] Failed to fetch from ${apiUrl}: ${response.status}`
+ );
+ return footprints;
+ }
+
+ const items = await response.json();
+
+ for (const item of items) {
+ if (item.type === 'file' && item.name.endsWith('.js')) {
+ // Fetch the content of the .js file
+ const contentResponse = await fetch(item.download_url);
+
+ // Check rate limit for file download
+ const fileRateLimitCheck = checkRateLimit(
+ contentResponse,
+ item.download_url
+ );
+ if (fileRateLimitCheck.error && !rateLimitTracker.warning) {
+ rateLimitTracker.warning = fileRateLimitCheck.error;
+ }
+ if (fileRateLimitCheck.isLimitExceeded) {
+ throw new Error(fileRateLimitCheck.error || 'Rate limit exceeded');
+ }
+
+ if (contentResponse.ok) {
+ const content = await contentResponse.text();
+ // Construct the footprint name from path and filename without extension
+ const fileName = item.name.replace(/\.js$/, '');
+ const name = basePath ? `${basePath}/${fileName}` : fileName;
+ console.log(`[GitHub] Loaded footprint: ${name}`);
+ footprints.push({ name, content });
+ }
+ } else if (item.type === 'dir') {
+ // Recursively fetch from subdirectory
+ const subPath = basePath ? `${basePath}/${item.name}` : item.name;
+ const subFootprints = await fetchFootprintsFromRepo(
+ owner,
+ repo,
+ branch,
+ subPath,
+ rateLimitTracker
+ );
+ footprints.push(...subFootprints);
+ }
+ }
+ } catch (error) {
+ console.warn('[GitHub] Failed to fetch footprints from repo:', error);
+ }
+
+ console.log(
+ `[GitHub] Loaded ${footprints.length} footprints from ${owner}/${repo}`
+ );
+ return footprints;
+};
+
+/**
+ * Recursively fetches all .js files from a GitHub directory and its subdirectories.
+ * @param {string} apiUrl - The GitHub API URL for the directory.
+ * @param {string} basePath - The base path for constructing footprint names.
+ * @returns {Promise} A promise that resolves with the list of footprints.
+ */
+const fetchFootprintsFromDirectory = async (
+ apiUrl: string,
+ basePath: string = '',
+ rateLimitTracker: { warning: string | null } = { warning: null }
+): Promise => {
+ console.log(`[GitHub] Fetching footprints from directory: ${apiUrl}`);
+ const footprints: GitHubFootprint[] = [];
+
+ try {
+ const response = await fetch(apiUrl);
+
+ // Check rate limit
+ const rateLimitCheck = checkRateLimit(response, apiUrl);
+ if (rateLimitCheck.error && !rateLimitTracker.warning) {
+ rateLimitTracker.warning = rateLimitCheck.error;
+ }
+ if (rateLimitCheck.isLimitExceeded) {
+ throw new Error(rateLimitCheck.error || 'Rate limit exceeded');
+ }
+
+ if (!response.ok) {
+ // Directory doesn't exist or is inaccessible, return empty array
+ console.log(`[GitHub] Directory not found or inaccessible: ${apiUrl}`);
+ return footprints;
+ }
+
+ const items = await response.json();
+
+ for (const item of items) {
+ if (item.type === 'file' && item.name.endsWith('.js')) {
+ // Fetch the content of the .js file
+ const contentResponse = await fetch(item.download_url);
+
+ // Check rate limit for file download
+ const fileRateLimitCheck = checkRateLimit(
+ contentResponse,
+ item.download_url
+ );
+ if (fileRateLimitCheck.error && !rateLimitTracker.warning) {
+ rateLimitTracker.warning = fileRateLimitCheck.error;
+ }
+ if (fileRateLimitCheck.isLimitExceeded) {
+ throw new Error(fileRateLimitCheck.error || 'Rate limit exceeded');
+ }
+
+ if (contentResponse.ok) {
+ const content = await contentResponse.text();
+ // Construct the footprint name from path and filename without extension
+ const fileName = item.name.replace(/\.js$/, '');
+ const name = basePath ? `${basePath}/${fileName}` : fileName;
+ console.log(`[GitHub] Loaded footprint: ${name}`);
+ footprints.push({ name, content });
+ }
+ } else if (item.type === 'dir') {
+ // Recursively fetch from subdirectory
+ const subPath = basePath ? `${basePath}/${item.name}` : item.name;
+ const subFootprints = await fetchFootprintsFromDirectory(
+ item.url,
+ subPath,
+ rateLimitTracker
+ );
+ footprints.push(...subFootprints);
+ }
+ }
+ } catch (error) {
+ // Silently fail if directory doesn't exist or can't be accessed
+ console.warn('[GitHub] Failed to fetch footprints from directory:', error);
+ }
+
+ console.log(`[GitHub] Loaded ${footprints.length} footprints from directory`);
+ return footprints;
+};
+
/**
* Fetches a configuration file (`config.yaml`) from a given GitHub URL.
* It handles repository root URLs and direct file URLs, automatically trying common branches ('main', 'master')
* and locations (`/config.yaml`, `/ergogen/config.yaml`).
+ * Also attempts to load footprints from a `footprints` folder alongside the config file.
* @param {string} url - The GitHub URL to fetch the configuration from.
- * @returns {Promise} A promise that resolves with the text content of the configuration file.
+ * @returns {Promise} A promise that resolves with the config content, footprints, and config path.
* @throws {Error} Throws an error if the fetch fails for all attempted locations.
*/
-export const fetchConfigFromUrl = async (url: string): Promise => {
+export const fetchConfigFromUrl = async (
+ url: string
+): Promise => {
+ console.log(`[GitHub] Starting fetch from URL: ${url}`);
let newUrl = url.trim();
+ // Track rate limit warnings throughout the loading process
+ const rateLimitTracker: { warning: string | null } = { warning: null };
+
const repoPattern = /^[a-zA-Z0-9-]+\/[a-zA-Z0-9_.-]+$/;
if (repoPattern.test(newUrl)) {
newUrl = `https://github.com/${newUrl}`;
@@ -29,6 +342,7 @@ export const fetchConfigFromUrl = async (url: string): Promise => {
}
const baseUrl = newUrl.endsWith('/') ? newUrl.slice(0, -1) : newUrl;
+ console.log(`[GitHub] Normalized URL: ${baseUrl}`);
/**
* Checks if a given URL points to the root of a GitHub repository.
@@ -79,38 +393,483 @@ export const fetchConfigFromUrl = async (url: string): Promise => {
if (!isRepoRoot(baseUrl)) {
const response = await fetch(getRawUrl(baseUrl));
if (!response.ok) {
+ if (response.status === 403) {
+ const rateLimitRemaining = response.headers.get(
+ 'X-RateLimit-Remaining'
+ );
+ if (rateLimitRemaining === '0') {
+ console.warn(
+ '[GitHub] Rate limit exceeded. Please wait and try again in about an hour.'
+ );
+ throw new Error(
+ 'GitHub API rate limit exceeded. Please wait and try again in about an hour.'
+ );
+ }
+ }
throw new Error(`HTTP error! status: ${response.status}`);
}
- return response.text();
+ const config = await response.text();
+
+ // Check if the file is named config.yaml to decide if we should look for footprints
+ const filename = baseUrl.split('/').pop() || '';
+ const shouldLoadFootprints = filename === 'config.yaml';
+
+ if (!shouldLoadFootprints) {
+ console.log(
+ '[GitHub] File is not config.yaml, skipping footprint loading'
+ );
+ return {
+ config,
+ footprints: [],
+ configPath: '',
+ rateLimitWarning: rateLimitTracker.warning || undefined,
+ };
+ }
+
+ // For config.yaml files, try to fetch footprints from the same directory
+ console.log(
+ '[GitHub] Direct config.yaml link, attempting to load footprints'
+ );
+
+ // Extract owner, repo, branch, and path from the URL
+ const urlMatch = baseUrl.match(
+ /github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)/
+ );
+ if (!urlMatch) {
+ console.warn(
+ '[GitHub] Could not parse direct file URL, skipping footprints'
+ );
+ return {
+ config,
+ footprints: [],
+ configPath: '',
+ rateLimitWarning: rateLimitTracker.warning || undefined,
+ };
+ }
+
+ const [, owner, repo, branch, filePath] = urlMatch;
+ const dirPath = filePath.substring(0, filePath.lastIndexOf('/'));
+ const footprintsPath = dirPath ? `${dirPath}/footprints` : 'footprints';
+ console.log(`[GitHub] Looking for footprints in: ${footprintsPath}`);
+
+ const footprintsApiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${footprintsPath}?ref=${branch}`;
+ const footprints = await fetchFootprintsFromDirectory(
+ footprintsApiUrl,
+ '',
+ rateLimitTracker
+ );
+
+ // Check for submodules
+ console.log('[GitHub] Checking for .gitmodules file');
+ try {
+ const gitmodulesUrl = getRawUrl(
+ `https://github.com/${owner}/${repo}/blob/${branch}/.gitmodules`
+ );
+ const gitmodulesResponse = await fetch(gitmodulesUrl);
+
+ // Check rate limit for .gitmodules fetch
+ const gitmodulesRateLimitCheck = checkRateLimit(
+ gitmodulesResponse,
+ gitmodulesUrl
+ );
+ if (gitmodulesRateLimitCheck.error && !rateLimitTracker.warning) {
+ rateLimitTracker.warning = gitmodulesRateLimitCheck.error;
+ }
+ if (gitmodulesRateLimitCheck.isLimitExceeded) {
+ throw new Error(
+ gitmodulesRateLimitCheck.error || 'Rate limit exceeded'
+ );
+ }
+
+ if (gitmodulesResponse.ok) {
+ console.log('[GitHub] .gitmodules found, parsing submodules');
+ const gitmodulesContent = await gitmodulesResponse.text();
+ const submodules = parseGitmodules(gitmodulesContent);
+
+ for (const submodule of submodules) {
+ if (submodule.path.startsWith(footprintsPath)) {
+ console.log(
+ `[GitHub] Processing submodule: ${submodule.path} -> ${submodule.url}`
+ );
+ const submoduleMatch = submodule.url.match(
+ /github\.com[/:]([^/]+)\/([^/.]+)/
+ );
+ if (submoduleMatch) {
+ const [, subOwner, subRepo] = submoduleMatch;
+ const relativePath = submodule.path.substring(
+ footprintsPath.length + 1
+ );
+ console.log(`[GitHub] Submodule relative path: ${relativePath}`);
+
+ let submoduleFootprints: GitHubFootprint[] = [];
+ try {
+ submoduleFootprints = await fetchFootprintsFromRepo(
+ subOwner,
+ subRepo,
+ 'main',
+ '',
+ rateLimitTracker
+ );
+ } catch (_e) {
+ try {
+ submoduleFootprints = await fetchFootprintsFromRepo(
+ subOwner,
+ subRepo,
+ 'master',
+ '',
+ rateLimitTracker
+ );
+ } catch (_e2) {
+ console.warn(
+ `Failed to fetch submodule footprints from ${submodule.url}`
+ );
+ }
+ }
+
+ const prefixedFootprints = submoduleFootprints.map((fp) => ({
+ name: relativePath ? `${relativePath}/${fp.name}` : fp.name,
+ content: fp.content,
+ }));
+ console.log(
+ `[GitHub] Added ${prefixedFootprints.length} footprints from submodule ${submodule.path}`
+ );
+ footprints.push(...prefixedFootprints);
+ }
+ } else {
+ console.log(
+ `[GitHub] Skipping submodule (not in footprints): ${submodule.path}`
+ );
+ }
+ }
+ } else {
+ console.log('[GitHub] No .gitmodules file found');
+ }
+ } catch (error) {
+ console.warn('[GitHub] Error checking for .gitmodules:', error);
+ }
+
+ console.log(
+ `[GitHub] Loaded ${footprints.length} footprints from direct link`
+ );
+ return {
+ config,
+ footprints,
+ configPath: dirPath,
+ rateLimitWarning: rateLimitTracker.warning || undefined,
+ };
}
/**
- * Attempts to fetch `config.yaml` from standard locations within a specific branch of a repository.
+ * Performs a breadth-first search to find YAML files in a repository.
+ * @param {string} owner - Repository owner.
+ * @param {string} repo - Repository name.
+ * @param {string} branch - Branch to search.
+ * @returns {Promise<{configYamls: {path: string, content: string}[], anyYamls: {path: string, content: string}[]}>}
+ */
+ const bfsForYamlFiles = async (
+ owner: string,
+ repo: string,
+ branch: string,
+ rateLimitTracker: { warning: string | null } = { warning: null }
+ ): Promise<{
+ configYamls: { path: string; content: string }[];
+ anyYamls: { path: string; content: string }[];
+ }> => {
+ const configYamls: { path: string; content: string }[] = [];
+ const anyYamls: { path: string; content: string }[] = [];
+ const queue: string[] = [''];
+ const visited = new Set();
+
+ while (queue.length > 0) {
+ const currentPath = queue.shift()!;
+ if (visited.has(currentPath)) continue;
+ visited.add(currentPath);
+
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${currentPath}?ref=${branch}`;
+
+ try {
+ const response = await fetch(apiUrl);
+
+ // Check rate limit
+ const bfsRateLimitCheck = checkRateLimit(response, apiUrl);
+ if (bfsRateLimitCheck.error && !rateLimitTracker.warning) {
+ rateLimitTracker.warning = bfsRateLimitCheck.error;
+ }
+ if (bfsRateLimitCheck.isLimitExceeded) {
+ throw new Error(bfsRateLimitCheck.error || 'Rate limit exceeded');
+ }
+
+ if (!response.ok) {
+ continue;
+ }
+
+ const items = await response.json();
+ if (!Array.isArray(items)) continue;
+
+ for (const item of items) {
+ if (item.type === 'file' && item.name.endsWith('.yaml')) {
+ const fileResponse = await fetch(item.download_url);
+
+ // Check rate limit for YAML file download
+ const yamlRateLimitCheck = checkRateLimit(
+ fileResponse,
+ item.download_url
+ );
+ if (yamlRateLimitCheck.error && !rateLimitTracker.warning) {
+ rateLimitTracker.warning = yamlRateLimitCheck.error;
+ }
+ if (yamlRateLimitCheck.isLimitExceeded) {
+ throw new Error(
+ yamlRateLimitCheck.error || 'Rate limit exceeded'
+ );
+ }
+
+ if (fileResponse.ok) {
+ const content = await fileResponse.text();
+ const filePath = item.path;
+
+ if (item.name === 'config.yaml') {
+ configYamls.push({ path: filePath, content });
+ } else {
+ anyYamls.push({ path: filePath, content });
+ }
+ }
+ } else if (item.type === 'dir') {
+ queue.push(item.path);
+ }
+ }
+ } catch (_error) {
+ // Continue searching other directories
+ continue;
+ }
+ }
+
+ return { configYamls, anyYamls };
+ };
+
+ /**
+ * Attempts to fetch `config.yaml` and footprints from standard locations within a specific branch of a repository.
* @param {string} branch - The branch to check (e.g., 'main', 'master').
- * @returns {Promise} A promise that resolves with the file content if found.
+ * @returns {Promise} A promise that resolves with the config, footprints, and config path.
* @throws {Error} Throws an error if the file cannot be fetched from any location in the branch.
*/
- const fetchWithBranch = async (branch: string): Promise => {
+ const fetchWithBranch = async (branch: string): Promise => {
+ console.log(`[GitHub] Attempting to fetch from branch: ${branch}`);
+ // Extract owner and repo from baseUrl
+ const urlObject = new URL(baseUrl);
+ const [, owner, repo] = urlObject.pathname.split('/');
+ console.log(`[GitHub] Repository: ${owner}/${repo}`);
+
+ let configPath = '';
+ let config = '';
+ let shouldLoadFootprints = true;
+
// First, try the root directory
- const firstUrl = getRawUrl(`${baseUrl}/blob/${branch}/config.yaml`);
- let response = await fetch(firstUrl);
+ const rootUrl = getRawUrl(`${baseUrl}/blob/${branch}/config.yaml`);
+ let response = await fetch(rootUrl);
- if (response.ok) {
- return response.text();
+ // Check rate limit
+ const rateLimitCheck = checkRateLimit(response, rootUrl);
+ if (rateLimitCheck.error && !rateLimitTracker.warning) {
+ rateLimitTracker.warning = rateLimitCheck.error;
+ }
+ if (rateLimitCheck.isLimitExceeded) {
+ throw new Error(rateLimitCheck.error || 'Rate limit exceeded');
}
- // If not found, try the /ergogen/ directory
- if (response.status === 400 || response.status === 404) {
- const secondUrl = getRawUrl(
+ if (response.ok) {
+ config = await response.text();
+ configPath = '';
+ console.log('[GitHub] Config found in root directory');
+ } else if (response.status === 400 || response.status === 404) {
+ // Try the /ergogen/ directory
+ const ergogenUrl = getRawUrl(
`${baseUrl}/blob/${branch}/ergogen/config.yaml`
);
- response = await fetch(secondUrl);
+ response = await fetch(ergogenUrl);
+
+ // Check rate limit
+ const ergogenRateLimitCheck = checkRateLimit(response, ergogenUrl);
+ if (ergogenRateLimitCheck.error && !rateLimitTracker.warning) {
+ rateLimitTracker.warning = ergogenRateLimitCheck.error;
+ }
+ if (ergogenRateLimitCheck.isLimitExceeded) {
+ throw new Error(ergogenRateLimitCheck.error || 'Rate limit exceeded');
+ }
+
if (response.ok) {
- return response.text();
+ config = await response.text();
+ configPath = 'ergogen';
+ console.log('[GitHub] Config found in ergogen/ directory');
+ } else {
+ // Perform breadth-first search for YAML files
+ console.log('[GitHub] Performing breadth-first search for YAML files');
+ const { configYamls, anyYamls } = await bfsForYamlFiles(
+ owner,
+ repo,
+ branch,
+ rateLimitTracker
+ );
+
+ if (configYamls.length > 0) {
+ // Use the first config.yaml found
+ const firstConfig = configYamls[0];
+ config = firstConfig.content;
+ configPath = firstConfig.path.substring(
+ 0,
+ firstConfig.path.lastIndexOf('/')
+ );
+ console.log(`[GitHub] Found config.yaml at: ${firstConfig.path}`);
+
+ if (configYamls.length > 1) {
+ console.log(
+ `[GitHub] Multiple config.yaml files found, using first: ${firstConfig.path}`
+ );
+ }
+ } else if (anyYamls.length > 0) {
+ // No config.yaml found, use first .yaml file
+ const firstYaml = anyYamls[0];
+ config = firstYaml.content;
+ configPath = firstYaml.path.substring(
+ 0,
+ firstYaml.path.lastIndexOf('/')
+ );
+ shouldLoadFootprints = false;
+ console.log(
+ `[GitHub] No config.yaml found, using: ${firstYaml.path}`
+ );
+ } else {
+ // No YAML files found at all
+ throw new Error('No YAML configuration files found in repository');
+ }
+ }
+ } else {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ // Only load footprints if we found a config.yaml file
+ if (!shouldLoadFootprints) {
+ console.log(
+ '[GitHub] Skipping footprint loading for non-config.yaml file'
+ );
+ return {
+ config,
+ footprints: [],
+ configPath,
+ rateLimitWarning: rateLimitTracker.warning || undefined,
+ };
+ }
+
+ // Now fetch footprints from the footprints folder
+ const footprintsPath = configPath
+ ? `${configPath}/footprints`
+ : 'footprints';
+ console.log(`[GitHub] Looking for footprints in: ${footprintsPath}`);
+ const footprintsApiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${footprintsPath}?ref=${branch}`;
+ const footprints = await fetchFootprintsFromDirectory(
+ footprintsApiUrl,
+ '',
+ rateLimitTracker
+ );
+
+ // Check for .gitmodules to handle submodules
+ console.log('[GitHub] Checking for .gitmodules file');
+ try {
+ const gitmodulesUrl = getRawUrl(`${baseUrl}/blob/${branch}/.gitmodules`);
+ const gitmodulesResponse = await fetch(gitmodulesUrl);
+
+ // Check rate limit for .gitmodules fetch
+ const gitmodulesRateLimitCheck = checkRateLimit(
+ gitmodulesResponse,
+ gitmodulesUrl
+ );
+ if (gitmodulesRateLimitCheck.error && !rateLimitTracker.warning) {
+ rateLimitTracker.warning = gitmodulesRateLimitCheck.error;
+ }
+ if (gitmodulesRateLimitCheck.isLimitExceeded) {
+ throw new Error(
+ gitmodulesRateLimitCheck.error || 'Rate limit exceeded'
+ );
}
+
+ if (gitmodulesResponse.ok) {
+ console.log('[GitHub] .gitmodules found, parsing submodules');
+ const gitmodulesContent = await gitmodulesResponse.text();
+ const submodules = parseGitmodules(gitmodulesContent);
+
+ // Filter submodules that are within the footprints folder
+ for (const submodule of submodules) {
+ if (submodule.path.startsWith(footprintsPath)) {
+ console.log(
+ `[GitHub] Processing submodule: ${submodule.path} -> ${submodule.url}`
+ );
+ // Extract owner and repo from submodule URL
+ const submoduleMatch = submodule.url.match(
+ /github\.com[/:]([^/]+)\/([^/.]+)/
+ );
+ if (submoduleMatch) {
+ const [, subOwner, subRepo] = submoduleMatch;
+ // Calculate the relative path for naming
+ const relativePath = submodule.path.substring(
+ footprintsPath.length + 1
+ );
+ console.log(`[GitHub] Submodule relative path: ${relativePath}`);
+ // Try both main and master branches for the submodule
+ let submoduleFootprints: GitHubFootprint[] = [];
+ try {
+ submoduleFootprints = await fetchFootprintsFromRepo(
+ subOwner,
+ subRepo,
+ 'main',
+ '',
+ rateLimitTracker
+ );
+ } catch (_e) {
+ try {
+ submoduleFootprints = await fetchFootprintsFromRepo(
+ subOwner,
+ subRepo,
+ 'master',
+ '',
+ rateLimitTracker
+ );
+ } catch (_e2) {
+ console.warn(
+ `Failed to fetch submodule footprints from ${submodule.url}`
+ );
+ }
+ }
+ // Prefix the footprint names with the relative path
+ const prefixedFootprints = submoduleFootprints.map((fp) => ({
+ name: relativePath ? `${relativePath}/${fp.name}` : fp.name,
+ content: fp.content,
+ }));
+ console.log(
+ `[GitHub] Added ${prefixedFootprints.length} footprints from submodule ${submodule.path}`
+ );
+ footprints.push(...prefixedFootprints);
+ }
+ } else {
+ console.log(
+ `[GitHub] Skipping submodule (not in footprints): ${submodule.path}`
+ );
+ }
+ }
+ } else {
+ console.log('[GitHub] No .gitmodules file found');
+ }
+ } catch (error) {
+ // .gitmodules doesn't exist or couldn't be parsed, continue without submodules
+ console.warn('[GitHub] No .gitmodules found or failed to parse:', error);
}
- // If still not found or another error occurred, throw.
- throw new Error(`HTTP error! status: ${response.status}`);
+
+ console.log(`[GitHub] Total footprints loaded: ${footprints.length}`);
+ return {
+ config,
+ footprints,
+ configPath,
+ rateLimitWarning: rateLimitTracker.warning || undefined,
+ };
};
// Try fetching from the 'main' branch first, then fall back to 'master'.
diff --git a/src/utils/injections.test.ts b/src/utils/injections.test.ts
new file mode 100644
index 00000000..a58b185f
--- /dev/null
+++ b/src/utils/injections.test.ts
@@ -0,0 +1,190 @@
+import {
+ checkForConflict,
+ generateUniqueName,
+ mergeInjections,
+} from './injections';
+
+describe('injections utilities', () => {
+ describe('checkForConflict', () => {
+ it('returns no conflict when existing injections are empty', () => {
+ // Arrange & Act
+ const result = checkForConflict('test_footprint', []);
+
+ // Assert
+ expect(result.hasConflict).toBe(false);
+ });
+
+ it('returns no conflict when existing injections are undefined', () => {
+ // Arrange & Act
+ const result = checkForConflict('test_footprint', undefined);
+
+ // Assert
+ expect(result.hasConflict).toBe(false);
+ });
+
+ it('returns no conflict when name does not exist', () => {
+ // Arrange
+ const existingInjections = [
+ ['footprint', 'existing_footprint', 'content'],
+ ];
+
+ // Act
+ const result = checkForConflict('new_footprint', existingInjections);
+
+ // Assert
+ expect(result.hasConflict).toBe(false);
+ });
+
+ it('returns conflict when name already exists', () => {
+ // Arrange
+ const existingInjections = [
+ ['footprint', 'existing_footprint', 'content'],
+ ];
+
+ // Act
+ const result = checkForConflict('existing_footprint', existingInjections);
+
+ // Assert
+ expect(result.hasConflict).toBe(true);
+ if (result.hasConflict) {
+ expect(result.conflictingName).toBe('existing_footprint');
+ }
+ });
+
+ it('ignores non-footprint injections', () => {
+ // Arrange
+ const existingInjections = [['template', 'existing_template', 'content']];
+
+ // Act
+ const result = checkForConflict('existing_template', existingInjections);
+
+ // Assert
+ expect(result.hasConflict).toBe(false);
+ });
+ });
+
+ describe('generateUniqueName', () => {
+ it('returns the base name when no conflicts exist', () => {
+ // Arrange & Act
+ const result = generateUniqueName('test_footprint', []);
+
+ // Assert
+ expect(result).toBe('test_footprint');
+ });
+
+ it('appends _1 when base name exists', () => {
+ // Arrange
+ const existingInjections = [['footprint', 'test_footprint', 'content']];
+
+ // Act
+ const result = generateUniqueName('test_footprint', existingInjections);
+
+ // Assert
+ expect(result).toBe('test_footprint_1');
+ });
+
+ it('increments number until unique name is found', () => {
+ // Arrange
+ const existingInjections = [
+ ['footprint', 'test_footprint', 'content'],
+ ['footprint', 'test_footprint_1', 'content'],
+ ['footprint', 'test_footprint_2', 'content'],
+ ];
+
+ // Act
+ const result = generateUniqueName('test_footprint', existingInjections);
+
+ // Assert
+ expect(result).toBe('test_footprint_3');
+ });
+ });
+
+ describe('mergeInjections', () => {
+ it('adds new footprints when no conflicts exist', () => {
+ // Arrange
+ const newFootprints = [
+ { name: 'footprint1', content: 'content1' },
+ { name: 'footprint2', content: 'content2' },
+ ];
+ const existingInjections: string[][] = [];
+
+ // Act
+ const result = mergeInjections(newFootprints, existingInjections, 'skip');
+
+ // Assert
+ expect(result).toHaveLength(2);
+ expect(result[0]).toEqual(['footprint', 'footprint1', 'content1']);
+ expect(result[1]).toEqual(['footprint', 'footprint2', 'content2']);
+ });
+
+ it('skips conflicting footprints when resolution is "skip"', () => {
+ // Arrange
+ const newFootprints = [{ name: 'existing', content: 'new_content' }];
+ const existingInjections = [['footprint', 'existing', 'old_content']];
+
+ // Act
+ const result = mergeInjections(newFootprints, existingInjections, 'skip');
+
+ // Assert
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual(['footprint', 'existing', 'old_content']);
+ });
+
+ it('overwrites conflicting footprints when resolution is "overwrite"', () => {
+ // Arrange
+ const newFootprints = [{ name: 'existing', content: 'new_content' }];
+ const existingInjections = [['footprint', 'existing', 'old_content']];
+
+ // Act
+ const result = mergeInjections(
+ newFootprints,
+ existingInjections,
+ 'overwrite'
+ );
+
+ // Assert
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual(['footprint', 'existing', 'new_content']);
+ });
+
+ it('keeps both footprints with unique name when resolution is "keep-both"', () => {
+ // Arrange
+ const newFootprints = [{ name: 'existing', content: 'new_content' }];
+ const existingInjections = [['footprint', 'existing', 'old_content']];
+
+ // Act
+ const result = mergeInjections(
+ newFootprints,
+ existingInjections,
+ 'keep-both'
+ );
+
+ // Assert
+ expect(result).toHaveLength(2);
+ expect(result[0]).toEqual(['footprint', 'existing', 'old_content']);
+ expect(result[1]).toEqual(['footprint', 'existing_1', 'new_content']);
+ });
+
+ it('handles mixed scenarios with conflicts and non-conflicts', () => {
+ // Arrange
+ const newFootprints = [
+ { name: 'existing', content: 'new_content' },
+ { name: 'new_one', content: 'new_one_content' },
+ ];
+ const existingInjections = [['footprint', 'existing', 'old_content']];
+
+ // Act
+ const result = mergeInjections(
+ newFootprints,
+ existingInjections,
+ 'keep-both'
+ );
+
+ // Assert
+ expect(result).toHaveLength(3);
+ expect(result[0]).toEqual(['footprint', 'existing', 'old_content']);
+ expect(result[1]).toEqual(['footprint', 'existing_1', 'new_content']);
+ expect(result[2]).toEqual(['footprint', 'new_one', 'new_one_content']);
+ });
+ });
+});
diff --git a/src/utils/injections.ts b/src/utils/injections.ts
new file mode 100644
index 00000000..7bf36e36
--- /dev/null
+++ b/src/utils/injections.ts
@@ -0,0 +1,112 @@
+/**
+ * Represents a conflict resolution strategy.
+ */
+export type ConflictResolution = 'skip' | 'overwrite' | 'keep-both';
+
+/**
+ * Result of checking for a conflict.
+ */
+export type ConflictCheckResult =
+ | { hasConflict: false }
+ | { hasConflict: true; conflictingName: string };
+
+/**
+ * Checks if a footprint name conflicts with existing injections.
+ * @param name - The name of the footprint to check.
+ * @param existingInjections - The array of existing injections.
+ * @returns A conflict check result indicating if there's a conflict and the name.
+ */
+export const checkForConflict = (
+ name: string,
+ existingInjections: string[][] | undefined
+): ConflictCheckResult => {
+ if (!existingInjections || existingInjections.length === 0) {
+ return { hasConflict: false };
+ }
+
+ const existingNames = existingInjections
+ .filter((inj) => inj.length === 3 && inj[0] === 'footprint')
+ .map((inj) => inj[1]);
+
+ if (existingNames.includes(name)) {
+ return { hasConflict: true, conflictingName: name };
+ }
+
+ return { hasConflict: false };
+};
+
+/**
+ * Generates a unique name by appending an incremental number.
+ * @param baseName - The base name to make unique.
+ * @param existingInjections - The array of existing injections.
+ * @returns A unique name.
+ */
+export const generateUniqueName = (
+ baseName: string,
+ existingInjections: string[][] | undefined
+): string => {
+ if (!existingInjections || existingInjections.length === 0) {
+ return baseName;
+ }
+
+ const existingNames = existingInjections
+ .filter((inj) => inj.length === 3 && inj[0] === 'footprint')
+ .map((inj) => inj[1]);
+
+ let counter = 1;
+ let newName = `${baseName}_${counter}`;
+
+ while (existingNames.includes(newName)) {
+ counter++;
+ newName = `${baseName}_${counter}`;
+ }
+
+ return newName;
+};
+
+/**
+ * Merges new footprints into existing injections based on the resolution strategy.
+ * @param newFootprints - Array of new footprints to merge.
+ * @param existingInjections - The current array of injections.
+ * @param resolution - The conflict resolution strategy.
+ * @returns The merged array of injections.
+ */
+export const mergeInjections = (
+ newFootprints: Array<{ name: string; content: string }>,
+ existingInjections: string[][] | undefined,
+ resolution: ConflictResolution
+): string[][] => {
+ const result = existingInjections ? [...existingInjections] : [];
+
+ for (const footprint of newFootprints) {
+ const conflictCheck = checkForConflict(footprint.name, result);
+
+ if (!conflictCheck.hasConflict) {
+ // No conflict, add directly
+ result.push(['footprint', footprint.name, footprint.content]);
+ } else {
+ // Handle conflict based on resolution strategy
+ if (resolution === 'skip') {
+ // Do nothing
+ continue;
+ } else if (resolution === 'overwrite') {
+ // Find and replace the existing injection
+ const index = result.findIndex(
+ (inj) =>
+ inj.length === 3 &&
+ inj[0] === 'footprint' &&
+ inj[1] === footprint.name
+ );
+ if (index !== -1) {
+ result[index] = ['footprint', footprint.name, footprint.content];
+ }
+ } else if (resolution === 'keep-both') {
+ // Generate a unique name and add
+ const uniqueName = generateUniqueName(footprint.name, result);
+ result.push(['footprint', uniqueName, footprint.content]);
+ }
+ }
+ }
+
+ return result;
+};