diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000000..d5d401c5189 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,31 @@ +# Use the latest 2.1 version of CircleCI pipeline process engine. +# See: https://circleci.com/docs/configuration-reference +version: 2.1 + +# Define a job to be invoked later in a workflow. +# See: https://circleci.com/docs/jobs-steps/#jobs-overview & https://circleci.com/docs/configuration-reference/#jobs +jobs: + say-hello: + # Specify the execution environment. You can specify an image from Docker Hub or use one of our convenience images from CircleCI's Developer Hub. + # See: https://circleci.com/docs/executor-intro/ & https://circleci.com/docs/configuration-reference/#executor-job + docker: + # Specify the version you desire here + # See: https://circleci.com/developer/images/image/cimg/base + - image: cimg/base:current + + # Add steps to the job + # See: https://circleci.com/docs/jobs-steps/#steps-overview & https://circleci.com/docs/configuration-reference/#steps + steps: + # Checkout the code as the first step. + - checkout + - run: + name: "Say hello" + command: "echo Hello, World!" + +# Orchestrate jobs using workflows +# See: https://circleci.com/docs/workflows/ & https://circleci.com/docs/configuration-reference/#workflows +workflows: + say-hello-workflow: # This is the name of the workflow, feel free to change it to better match your workflow. + # Inside the workflow, you define the jobs you want to run. + jobs: + - say-hello diff --git a/.circleci/docker.yml b/.circleci/docker.yml new file mode 100644 index 00000000000..e994f94e708 --- /dev/null +++ b/.circleci/docker.yml @@ -0,0 +1,100 @@ +name: Docker + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +on: + schedule: + - cron: '21 12 * * *' + push: + branches: [ "master" ] + # Publish semver tags as releases. + tags: [ 'v*.*.*' ] + pull_request: + branches: [ "master" ] + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + + +jobs: + build: + - name: Build the Docker image + run: docker build . --file path/to/Dockerfile --tag my-image-name:$(date +%s) + + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + # This is used to complete the identity challenge + # with sigstore/fulcio when running outside of PRs. + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Install the cosign tool except on PR + # https://github.com/sigstore/cosign-installer + - name: Install cosign + if: github.event_name != 'pull_request' + uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0 + with: + cosign-release: 'v2.2.4' + + # Set up BuildKit Docker container builder to be able to build + # multi-platform images and export cache + # https://github.com/docker/setup-buildx-action + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 + + # Login against a Docker registry except on PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + # Build and push Docker image with Buildx (don't push on PR) + # https://github.com/docker/build-push-action + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@v5.0.0 + with: + context: ./ + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # Sign the resulting Docker image digest except on PRs. + # This will only write to the public Rekor transparency log when the Docker + # repository is public to avoid leaking data. If you would like to publish + # transparency data even for private images, pass --force to cosign below. + # https://github.com/sigstore/cosign + - name: Sign the published Docker image + if: ${{ github.event_name != 'pull_request' }} + env: + # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable + TAGS: ${{ steps.meta.outputs.tags }} + DIGEST: ${{ steps.build-and-push.outputs.digest }} + # This step uses the identity token to provision an ephemeral certificate + # against the sigstore community Fulcio instance. + run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} diff --git a/.claude/hooks/skill-activation-prompt.sh b/.claude/hooks/skill-activation-prompt.sh new file mode 100755 index 00000000000..27fb24d0584 --- /dev/null +++ b/.claude/hooks/skill-activation-prompt.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Copied from https://github.com/diet103/claude-code-infrastructure-showcase/blob/c586f9d8854989abbe9040cde61527888ded3904/.claude/hooks/skill-activation-prompt.sh +set -e + +cd "$CLAUDE_PROJECT_DIR/.claude/hooks" +cat | bun run skill-activation-prompt.ts diff --git a/.claude/hooks/skill-activation-prompt.ts b/.claude/hooks/skill-activation-prompt.ts new file mode 100644 index 00000000000..f8feef6343d --- /dev/null +++ b/.claude/hooks/skill-activation-prompt.ts @@ -0,0 +1,132 @@ +#!/usr/bin/env node +/** biome-ignore-all lint/suspicious/noConsole: script output */ +// Copied from https://github.com/diet103/claude-code-infrastructure-showcase/blob/c586f9d8854989abbe9040cde61527888ded3904/.claude/hooks/skill-activation-prompt.ts +import { readFileSync } from 'fs' +import { join } from 'path' + +interface HookInput { + session_id: string + transcript_path: string + cwd: string + permission_mode: string + prompt: string +} + +interface PromptTriggers { + keywords?: string[] + intentPatterns?: string[] +} + +interface SkillRule { + type: 'guardrail' | 'domain' + enforcement: 'block' | 'suggest' | 'warn' + priority: 'critical' | 'high' | 'medium' | 'low' + promptTriggers?: PromptTriggers +} + +interface SkillRules { + version: string + skills: Record +} + +interface MatchedSkill { + name: string + matchType: 'keyword' | 'intent' + config: SkillRule +} + +async function main() { + try { + // Read input from stdin + const input = readFileSync(0, 'utf-8') + const data: HookInput = JSON.parse(input) + const prompt = data.prompt.toLowerCase() + + // Load skill rules + const projectDir = process.env.CLAUDE_PROJECT_DIR || '$HOME/project' + const rulesPath = join(projectDir, '.claude', 'skills', 'skill-rules.json') + const rules: SkillRules = JSON.parse(readFileSync(rulesPath, 'utf-8')) + + const matchedSkills: MatchedSkill[] = [] + + // Check each skill for matches + for (const [skillName, config] of Object.entries(rules.skills)) { + const triggers = config.promptTriggers + if (!triggers) { + continue + } + + // Keyword matching + if (triggers.keywords) { + const keywordMatch = triggers.keywords.some((kw) => prompt.includes(kw.toLowerCase())) + if (keywordMatch) { + matchedSkills.push({ name: skillName, matchType: 'keyword', config }) + continue + } + } + + // Intent pattern matching + if (triggers.intentPatterns) { + const intentMatch = triggers.intentPatterns.some((pattern) => { + const regex = new RegExp(pattern, 'i') + return regex.test(prompt) + }) + if (intentMatch) { + matchedSkills.push({ name: skillName, matchType: 'intent', config }) + } + } + } + + // Generate output if matches found + if (matchedSkills.length > 0) { + let output = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' + output += '🎯 SKILL ACTIVATION CHECK\n' + output += '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n' + + // Group by priority + const critical = matchedSkills.filter((s) => s.config.priority === 'critical') + const high = matchedSkills.filter((s) => s.config.priority === 'high') + const medium = matchedSkills.filter((s) => s.config.priority === 'medium') + const low = matchedSkills.filter((s) => s.config.priority === 'low') + + if (critical.length > 0) { + output += '⚠️ CRITICAL SKILLS (REQUIRED):\n' + critical.forEach((s) => (output += ` → ${s.name}\n`)) + output += '\n' + } + + if (high.length > 0) { + output += '📚 RECOMMENDED SKILLS:\n' + high.forEach((s) => (output += ` → ${s.name}\n`)) + output += '\n' + } + + if (medium.length > 0) { + output += '💡 SUGGESTED SKILLS:\n' + medium.forEach((s) => (output += ` → ${s.name}\n`)) + output += '\n' + } + + if (low.length > 0) { + output += '📌 OPTIONAL SKILLS:\n' + low.forEach((s) => (output += ` → ${s.name}\n`)) + output += '\n' + } + + output += 'ACTION: Use Skill tool BEFORE responding\n' + output += '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' + + console.log(output) + } + + process.exit(0) + } catch (err) { + console.error('Error in skill-activation-prompt hook:', err) + process.exit(1) + } +} + +main().catch((err) => { + console.error('Uncaught error:', err) + process.exit(1) +}) diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000000..98c9f2f7c89 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,32 @@ +{ + "permissions": { + "deny": [ + "Read(**/.env)", + "Edit(**/.env)", + "Read(~/.aws/**)", + "Edit(~/.aws/**)", + "Read(~/.ssh/**)", + "Edit(~/.ssh/**)", + "Read(~/.gnupg/**)", + "Edit(~/.gnupg/**)", + "Read(~/.git-credentials)", + "Edit(~/.git-credentials)", + "Read($HOME/Library/Keychains/**)", + "Edit($HOME/Library/Keychains/**)", + "Read(/private/etc/**)", + "Edit(/private/etc/**)" + ] + }, + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/skill-activation-prompt.sh" + } + ] + } + ] + } +} diff --git a/.claude/skills/skill-rules.json b/.claude/skills/skill-rules.json new file mode 100644 index 00000000000..83577579083 --- /dev/null +++ b/.claude/skills/skill-rules.json @@ -0,0 +1,29 @@ +{ + "version": "1.0", + "description": "Skill activation triggers for Claude Code. Controls when skills automatically suggest or block actions.", + "skills": { + "web-e2e": { + "type": "domain", + "enforcement": "block", + "priority": "critical", + "description": "Run, debug, and create Playwright e2e tests for the web app.", + "promptTriggers": { + "keywords": ["e2e", "end-to-end", "playwright"], + "intentPatterns": ["(run|start|debug|create|explain).*?e2e"] + } + } + }, + "notes": { + "enforcement_types": { + "suggest": "Skill suggestion appears but doesn't block execution", + "block": "Requires skill to be used before proceeding (guardrail)", + "warn": "Shows warning but allows proceeding" + }, + "priority_levels": { + "critical": "Highest - Always trigger when matched", + "high": "Important - Trigger for most matches", + "medium": "Moderate - Trigger for clear matches", + "low": "Optional - Trigger only for explicit matches" + } + } +} diff --git a/.claude/skills/web-e2e/SKILL.md b/.claude/skills/web-e2e/SKILL.md new file mode 100644 index 00000000000..aad00d76d9b --- /dev/null +++ b/.claude/skills/web-e2e/SKILL.md @@ -0,0 +1,350 @@ +--- +name: web-e2e +description: Run, create, and debug Playwright e2e tests for the web app. ALWAYS invoke this skill using the SlashCommand tool (i.e., `/web-e2e`) BEFORE attempting to run any e2e tests, playwright tests, anvil tests, or debug test failures. DO NOT run `bun playwright test` or other e2e commands directly - you must invoke this skill first to learn the correct commands and test architecture. +allowed-tools: [Read, Write, Edit, Bash, Glob, Grep, mcp__playwright__browser_navigate, mcp__playwright__browser_snapshot, mcp__playwright__browser_click, mcp__playwright__browser_type, mcp__playwright__browser_take_screenshot, mcp__playwright__browser_console_messages, mcp__playwright__browser_network_requests, mcp__playwright__browser_evaluate] +--- + +# Web E2E Testing Skill + +This skill helps you create and run end-to-end (e2e) Playwright tests for the Uniswap web application. + +## Test Architecture + +### Test Location +- All e2e tests live in `apps/web/src/` directory structure +- Test files use the naming convention: `*.e2e.test.ts` +- Anvil-specific tests (requiring local blockchain): `*.anvil.e2e.test.ts` + +### Automatic Wallet Connection + +**Important**: When running Playwright tests, the app automatically connects to a test wallet: +- **Address**: `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` (constant: `TEST_WALLET_ADDRESS`) +- **Display name**: `test0` (the Unitag associated with this address) +- **Connection**: Happens automatically via `wagmiAutoConnect.ts` when in Playwright environment + +This means: +- Tests start with a wallet already connected +- You can immediately test wallet-dependent features +- The wallet button will show "test0" instead of "Connect wallet" + +**When using Playwright MCP**: To enable automatic wallet connection when browsing via MCP tools, set the environment variable `REACT_APP_IS_PLAYWRIGHT_ENV=true` before starting the dev server. This makes the app behave identically to how it does in automated tests, with the test wallet auto-connected. + +### Custom Fixtures + +The web app uses custom Playwright fixtures and mocks that extend base Playwright functionality. +They are located in `apps/web/src/playwright/fixtures/*` and `apps/web/src/playwright/mocks/*`. + +#### Import Pattern +```typescript +import { expect, getTest } from 'playwright/fixtures' + +// For regular tests (no blockchain) +const test = getTest() + +// For anvil tests (with blockchain) +const test = getTest({ withAnvil: true }) +``` + +#### Available Fixtures + +1. **graphql** - Mock GraphQL responses + ```typescript + await graphql.intercept('OperationName', Mocks.Path.to_mock) + await graphql.waitForResponse('OperationName') + ``` + +2. **anvil** - Local blockchain client (only in anvil tests) + ```typescript + // Set token balances + await anvil.setErc20Balance({ address, balance }) + + // Check balances + await anvil.getBalance({ address }) + await anvil.getErc20Balance(tokenAddress, ownerAddress) + + // Manage allowances + await anvil.setErc20Allowance({ address, spender, amount }) + await anvil.setPermit2Allowance({ token, spender, amount }) + + // Mining blocks + await anvil.mine({ blocks: 1 }) + + // Snapshots for isolation + const snapshotId = await anvil.takeSnapshot() + await anvil.revertToSnapshot(snapshotId) + ``` + +3. **tradingApi** - Mock Trading API responses + ```typescript + await stubTradingApiEndpoint({ + page, + endpoint: uniswapUrls.tradingApiPaths.swap + }) + ``` + +4. **amplitude** - Analytics mocking (automatic) + +### Test Structure + +```typescript +import { expect, getTest } from 'playwright/fixtures' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' + +const test = getTest({ withAnvil: true }) // or getTest() for non-anvil + +test.describe('Feature Name', () => { + test.beforeEach(async ({ page }) => { + // Setup before each test + }) + + test('should do something', async ({ page, anvil, graphql }) => { + // Setup mocks + await graphql.intercept('Operation', Mocks.Path.mock) + + // Setup blockchain state (if anvil test) + await anvil.setErc20Balance({ address, balance }) + + // Navigate to page + await page.goto('/path') + + // Interact with UI using TestIDs + await page.getByTestId(TestID.SomeButton).click() + + // Make assertions + await expect(page.getByText('Expected Text')).toBeVisible() + }) +}) +``` + +### Best Practices + +1. **Use TestIDs** - Always use the TestID enum for selectors (not string literals) + ```typescript + // Good + await page.getByTestId(TestID.ReviewSwap) + + // Bad + await page.getByTestId('review-swap') + ``` + +2. **Mock External Services** - Use fixtures to mock GraphQL, Trading API, REST API etc. + ```typescript + await graphql.intercept('PortfolioBalances', Mocks.PortfolioBalances.test_wallet) + await stubTradingApiEndpoint({ page, endpoint: uniswapUrls.tradingApiPaths.quote }) + ``` + +3. **Use Mocks Helper** - Import mock paths from `playwright/mocks/mocks.ts` + ```typescript + import { Mocks } from 'playwright/mocks/mocks' + await graphql.intercept('Token', Mocks.Token.uni_token) + ``` + +4. **Test Constants** - Use constants from the codebase + ```typescript + import { USDT, DAI } from 'uniswap/src/constants/tokens' + import { TEST_WALLET_ADDRESS } from 'playwright/fixtures/wallets' + + // TEST_WALLET_ADDRESS is the automatically connected wallet + // It displays as "test0" in the UI + ``` + +5. **Anvil State Management** - Set up blockchain state properly + ```typescript + // Always set token balances before testing swaps + await anvil.setErc20Balance({ + address: assume0xAddress(USDT.address), + balance: 100_000_000n + }) + ``` + +## Running Tests + +The following commands must be run from the `apps/web/` folder. + +**⚠️ PREREQUISITE**: Playwright tests require the Vite preview server to be running at `http://localhost:3000` BEFORE tests start. The `bun e2e` commands handle this automatically, but if running tests directly you must start the server first. + +### Development Commands + +The `e2e` commands handle all requisite setup tasks for the playwright tests. These include building the app for production and running the Vite preview server. + +```bash +# Run all e2e tests (starts anvil, builds, and runs tests) +bun e2e + +# Run only non-anvil tests (faster, no blockchain required) +bun e2e:no-anvil + +# Run only anvil tests (blockchain tests only) +bun e2e:anvil + +# Run specific test file +bun e2e TokenSelector.e2e.test +``` + +### Direct Playwright Commands + +In some cases it may be helpful to run the commands more directly with the different tasks in different terminals. + +```bash +# Step 1: Build the web app for e2e +bun build:e2e + +# Step 2: Start the Vite preview server (REQUIRED - must be running before tests) +bun preview:e2e +# Wait for "Local: http://localhost:3000" message + +# (Optional) Step 3: Start Anvil (note, Anvil tests can start this themselves) +bun anvil:mainnet +# Wait for "Listening on 127.0.0.1:8545" message + +# Step 4: Run the playwright tests (only after servers are ready) +bun playwright:test +``` + +### Test Modes + +```bash +# Headed mode (see browser) +bun playwright test --headed + +# Debug mode with Playwright Inspector +bun playwright test --debug + +# UI mode (interactive) +bun playwright test --ui +``` + +## Configuration + +### Playwright Config (`playwright.config.ts`) + +Key settings: +- `testDir`: `./src` +- `testMatch`: `**/*.e2e.test.ts` +- `workers`: 1 (configured in CI) +- `fullyParallel`: false +- `baseURL`: `http://localhost:3000` + +## Common Patterns + +### Navigation and URL Testing +```typescript +await page.goto('/swap?inputCurrency=ETH&outputCurrency=USDT') +await expect(page.getByTestId(TestID.ChooseInputToken + '-label')).toHaveText('ETH') +``` + +### Form Interactions +```typescript +await page.getByTestId(TestID.AmountInputIn).fill('0.01') +await page.getByTestId(TestID.AmountInputIn).clear() +``` + +### Token Selection +```typescript +await page.getByTestId(TestID.ChooseOutputToken).click() +await page.getByTestId('token-option-1-USDT').first().click() +``` + +### Waiting for Transaction Completion +```typescript +await page.getByTestId(TestID.Swap).click() +await expect(page.getByText('Swapped')).toBeVisible() +``` + +### Blockchain Verification +```typescript +const balance = await anvil.getBalance({ address: TEST_WALLET_ADDRESS }) +await expect(balance).toBeLessThan(parseEther('10000')) +``` + +## Troubleshooting + +### Tests Timeout +- Check if Anvil is running: `bun anvil:mainnet` +- Ensure preview server is running: `bun preview:e2e` + +### Anvil Issues +- Tests automatically manage Anvil snapshots for isolation +- Anvil restarts automatically if unhealthy +- For manual restart: stop the e2e command and run again + +### Mock Not Working +- Ensure mock path is correct in `Mocks` object +- Check GraphQL operation name matches exactly +- Verify timing - intercept before the request is made + +### Test Flakiness +- Use proper waiting: `await expect(element).toBeVisible()` +- Don't use fixed `setTimeout` - use Playwright's auto-waiting +- Check for race conditions with network requests + +### Debugging + +- Run tests with `--headed` flag to watch the browser +- Use `--debug` flag to step through with Playwright Inspector +- Add `await page.pause()` in your test to stop at a specific point +- Check test output and error messages carefully +- Review screenshots/videos in `test-results/` directory after failures + +## Playwright Documentation References + +For more details on Playwright features, refer to: + +- **[Writing Tests](https://playwright.dev/docs/writing-tests)** - Test structure, actions, assertions +- **[Test Fixtures](https://playwright.dev/docs/test-fixtures)** - Creating custom fixtures (like our anvil/graphql fixtures) +- **[Running Tests](https://playwright.dev/docs/running-tests)** - Command line options, filtering, debugging +- **[API Testing](https://playwright.dev/docs/api-testing)** - Mocking and intercepting network requests +- **[Locators](https://playwright.dev/docs/locators)** - Finding elements (we use `getByTestId` primarily) +- **[Assertions](https://playwright.dev/docs/test-assertions)** - Available expect matchers +- **[Test Hooks](https://playwright.dev/docs/api/class-test#test-before-each)** - beforeEach, afterEach, beforeAll, afterAll +- **[Test Configuration](https://playwright.dev/docs/test-configuration)** - playwright.config.ts options +- **[Debugging Tests](https://playwright.dev/docs/debug)** - UI mode, inspector, trace viewer + +## Playwright MCP Integration (Optional but Recommended) + +The Playwright MCP (Model Context Protocol) provides browser automation capabilities that make test development and debugging easier: +- **Interactive debugging** - Navigate the app in a real browser to understand behavior +- **Creating tests** - Explore the UI to identify selectors and interactions +- **Debugging failures** - Inspect page state when tests fail + +### Installing Playwright MCP + +If you don't have the Playwright MCP installed, you can add it to your Claude Code configuration: + +1. Open Claude Code settings (Command/Ctrl + Shift + P → "Claude Code: Open Settings") +2. Add the Playwright MCP to your `mcpServers` configuration: + +```json +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": ["-y", "@executeautomation/playwright-mcp-server"] + } + } +} +``` + +3. Restart Claude Code + +Alternatively, follow the installation guide at: https://github.com/executeautomation/playwright-mcp + +### Using Playwright MCP for Test Development (Optional) + +**If you have the MCP installed**, you can use these tools during development: + +1. **Navigate and explore** - Use `mcp__playwright__browser_navigate` to visit pages +2. **Take snapshots** - Use `mcp__playwright__browser_snapshot` to see the page structure and find TestIDs +3. **Interact with elements** - Use `mcp__playwright__browser_click` and `mcp__playwright__browser_type` to test interactions +4. **Inspect state** - Use `mcp__playwright__browser_console_messages` and `mcp__playwright__browser_network_requests` to debug +5. **Take screenshots** - Use `mcp__playwright__browser_take_screenshot` to visualize issues + +## When to Use This Skill + +Use this skill when you need to: +- Create new end-to-end tests for web features +- Debug or fix failing e2e tests +- Run e2e tests during development +- Understand the e2e testing architecture +- Set up test fixtures or mocks +- Work with Anvil blockchain state in tests diff --git a/.cursor/cli.json b/.cursor/cli.json new file mode 100644 index 00000000000..b50608cd785 --- /dev/null +++ b/.cursor/cli.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "permissions": { + "deny": [ + "Read(**/.env)", + "Write(**/.env)", + "Read(~/.aws/**)", + "Write(~/.aws/**)", + "Read(~/.ssh/**)", + "Write(~/.ssh/**)", + "Read(~/.gnupg/**)", + "Write(~/.gnupg/**)", + "Read(~/.git-credentials)", + "Write(~/.git-credentials)", + "Read($HOME/Library/Keychains/**)", + "Write($HOME/Library/Keychains/**)", + "Read(/private/etc/**)", + "Write(/private/etc/**)" + ] + } + } diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index b01ad10c152..feac4b614be 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -3,6 +3,8 @@ name: Bug Report about: Report a bug or unexpected behavior in the Uniswap interfaces. title: "[Bug] " labels: bug +assignees: '' + --- ## 📱 Interface Affected diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md new file mode 100644 index 00000000000..48d5f81fa42 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/custom.md @@ -0,0 +1,10 @@ +--- +name: Custom issue template +about: Describe this issue template's purpose here. +title: '' +labels: '' +assignees: '' + +--- + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000000..36014cde565 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: 'enhancement' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/jekyll-gh-pages.yml b/.github/workflows/jekyll-gh-pages.yml new file mode 100644 index 00000000000..e31d81c5864 --- /dev/null +++ b/.github/workflows/jekyll-gh-pages.yml @@ -0,0 +1,51 @@ +# Sample workflow for building and deploying a Jekyll site to GitHub Pages +name: Deploy Jekyll with GitHub Pages dependencies preinstalled + +on: + # Runs on pushes targeting the default branch + push: + branches: ["main"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + # Build job + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Pages + uses: actions/configure-pages@v5 + - name: Build with Jekyll + uses: actions/jekyll-build-pages@v1 + with: + source: ./ + destination: ./_site + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + + # Deployment job + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml new file mode 100644 index 00000000000..f2c9e97c91d --- /dev/null +++ b/.github/workflows/static.yml @@ -0,0 +1,43 @@ +# Simple workflow for deploying static content to GitHub Pages +name: Deploy static content to Pages + +on: + # Runs on pushes targeting the default branch + push: + branches: ["main"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + # Single deploy job since we're just deploying + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Pages + uses: actions/configure-pages@v5 + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + # Upload entire repository + path: '.' + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/tag_and_release.yml b/.github/workflows/tag_and_release.yml index 3bfcc2933d0..3791e6f9a72 100644 --- a/.github/workflows/tag_and_release.yml +++ b/.github/workflows/tag_and_release.yml @@ -3,7 +3,10 @@ on: push: branches: - 'main' - +permissions: + contents: write + issues: write + pull-requests: write jobs: deploy-to-prod: runs-on: ubuntu-latest @@ -47,7 +50,7 @@ jobs: - name: 🪽 Release uses: actions/create-release@c9ba6969f07ed90fae07e2e66100dd03f9b1a50e env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Or use your PAT with: tag_name: ${{ steps.github-tag-action.outputs.new_tag }} release_name: Release ${{ steps.github-tag-action.outputs.new_tag }} diff --git a/CODEOWNERS b/CODEOWNERS deleted file mode 100644 index f70773659eb..00000000000 --- a/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -* @uniswap/web-admins diff --git a/RELEASE b/RELEASE index 1955213d6e7..c9728a812bc 100644 --- a/RELEASE +++ b/RELEASE @@ -1,14 +1,14 @@ -IPFS hash of the deployment: -- CIDv0: `QmYspBNPhKLM5x5H3iXQtHeMpCf3SpEg9thsoyDUco2cBK` -- CIDv1: `bafybeie4sckqrwo64q345nauc4gmqfw3oy2oevjqyvz6qicekmtmi2qy5i` - -The latest release is always mirrored at [app.uniswap.org](https://app.uniswap.org). - -You can also access the Uniswap Interface from an IPFS gateway. -**BEWARE**: The Uniswap interface uses [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) to remember your settings, such as which tokens you have imported. -**You should always use an IPFS gateway that enforces origin separation**, or our hosted deployment of the latest release at [app.uniswap.org](https://app.uniswap.org). -Your Uniswap settings are never remembered across different URLs. - -IPFS gateways: -- https://bafybeie4sckqrwo64q345nauc4gmqfw3oy2oevjqyvz6qicekmtmi2qy5i.ipfs.dweb.link/ -- [ipfs://QmYspBNPhKLM5x5H3iXQtHeMpCf3SpEg9thsoyDUco2cBK/](ipfs://QmYspBNPhKLM5x5H3iXQtHeMpCf3SpEg9thsoyDUco2cBK/) +IPFS hash of the deployment: +- CIDv0: `QmYspBNPhKLM5x5H3iXQtHeMpCf3SpEg9thsoyDUco2cBK` +- CIDv1: `bafybeie4sckqrwo64q345nauc4gmqfw3oy2oevjqyvz6qicekmtmi2qy5i` + +The latest release is always mirrored at [app.uniswap.org](https://app.uniswap.org). + +You can also access the Uniswap Interface from an IPFS gateway. +**BEWARE**: The Uniswap interface uses [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) to remember your settings, such as which tokens you have imported. +**You should always use an IPFS gateway that enforces origin separation**, or our hosted deployment of the latest release at [app.uniswap.org](https://app.uniswap.org). +Your Uniswap settings are never remembered across different URLs. + +IPFS gateways: +- https://bafybeie4sckqrwo64q345nauc4gmqfw3oy2oevjqyvz6qicekmtmi2qy5i.ipfs.dweb.link/ +- [ipfs://QmYspBNPhKLM5x5H3iXQtHeMpCf3SpEg9thsoyDUco2cBK/](ipfs://QmYspBNPhKLM5x5H3iXQtHeMpCf3SpEg9thsoyDUco2cBK/) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000000..034e8480320 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Supported Versions + +Use this section to tell people about which versions of your project are +currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| 5.1.x | :white_check_mark: | +| 5.0.x | :x: | +| 4.0.x | :white_check_mark: | +| < 4.0 | :x: | + +## Reporting a Vulnerability + +Use this section to tell people how to report a vulnerability. + +Tell them where to go, how often they can expect to get an update on a +reported vulnerability, what to expect if the vulnerability is accepted or +declined, etc. diff --git a/apps/api-self-serve/.eslintrc.js b/apps/api-self-serve/.eslintrc.js new file mode 100644 index 00000000000..bc045be8575 --- /dev/null +++ b/apps/api-self-serve/.eslintrc.js @@ -0,0 +1,44 @@ +const restrictedGlobals = require('confusing-browser-globals') +const rulesDirPlugin = require('eslint-plugin-rulesdir') +rulesDirPlugin.RULES_DIR = '../../packages/uniswap/eslint_rules' + +module.exports = { + root: true, + extends: ['@uniswap/eslint-config/extension'], + plugins: ['rulesdir'], + ignorePatterns: [ + 'node_modules', + '.react-router', + 'dist', + 'build', + '.eslintrc.js', + 'manifest.json', + '.nx', + 'vite.config.ts', + ], + parserOptions: { + project: 'tsconfig.eslint.json', + tsconfigRootDir: __dirname, + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 2018, + sourceType: 'module', + }, + rules: { + 'rulesdir/i18n': 'error', + }, + overrides: [ + { + files: ['*.ts', '*.tsx'], + rules: { + 'no-relative-import-paths/no-relative-import-paths': [ + 'error', + { + allowSameFolder: false, + }, + ], + }, + }, + ], +} diff --git a/apps/api-self-serve/.gitignore b/apps/api-self-serve/.gitignore new file mode 100644 index 00000000000..039ee62d21a --- /dev/null +++ b/apps/api-self-serve/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +.env +/node_modules/ + +# React Router +/.react-router/ +/build/ diff --git a/apps/api-self-serve/README.md b/apps/api-self-serve/README.md new file mode 100644 index 00000000000..25d1b31c061 --- /dev/null +++ b/apps/api-self-serve/README.md @@ -0,0 +1 @@ +# API Self Serve Portal diff --git a/apps/api-self-serve/app/app.css b/apps/api-self-serve/app/app.css new file mode 100644 index 00000000000..abd90f244db --- /dev/null +++ b/apps/api-self-serve/app/app.css @@ -0,0 +1,170 @@ +@import "tailwindcss/preflight"; +@import "tailwindcss"; +@plugin "tailwindcss-animate"; +@tailwind utilities; +@config "../tailwind.config.ts"; + +@custom-variant dark (&:is(.dark *)); + +@font-face { + font-family: "Basel Grotesk"; + src: url("https://app.uniswap.org/fonts/Basel-Grotesk-Book.woff2") + format("woff2"); + font-weight: 485; + font-style: normal; + font-display: swap; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +@font-face { + font-family: "Basel Grotesk"; + src: url("https://app.uniswap.org/fonts/Basel-Grotesk-Medium.woff2") + format("woff2"); + font-weight: 535; + font-style: normal; + font-display: swap; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +@layer base { + html, body { + @apply bg-background text-foreground font-basel; + } + * { + @apply border-border outline-ring/50; + } +} + +/* Light mode is the default */ +:root { + color-scheme: light; + --font-basel: "Basel Grotesk"; + /* Light mode shadows */ + --shadow-short: + 0px 1px 6px 2px rgba(0, 0, 0, 0.03), 0px 1px 2px 0px rgba(0, 0, 0, 0.02); + --shadow-medium: + 0px 6px 12px -3px rgba(19, 19, 19, 0.04), + 0px 2px 5px -2px rgba(19, 19, 19, 0.03); + --shadow-large: + 0px 10px 20px -5px rgba(19, 19, 19, 0.05), + 0px 4px 12px -3px rgba(19, 19, 19, 0.04); + /*shadcn*/ + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +/* Dark mode applies when .dark class is present */ +.dark { + color-scheme: dark; + /* Dark mode shadows */ + --shadow-short: + 0px 1px 3px 0px rgba(0, 0, 0, 0.12), 0px 1px 2px 0px rgba(0, 0, 0, 0.24); + --shadow-medium: + 0px 10px 15px -3px rgba(19, 19, 19, 0.54), + 0px 4px 6px -2px rgba(19, 19, 19, 0.4); + --shadow-large: + 0px 16px 24px -6px rgba(0, 0, 0, 0.6), 0px 8px 12px -4px rgba(0, 0, 0, 0.48); + /*shadcn*/ + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +/*shadcn*/ +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} diff --git a/apps/api-self-serve/app/lib/utils.ts b/apps/api-self-serve/app/lib/utils.ts new file mode 100644 index 00000000000..d32b0fe652e --- /dev/null +++ b/apps/api-self-serve/app/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/apps/api-self-serve/app/root.tsx b/apps/api-self-serve/app/root.tsx new file mode 100644 index 00000000000..9b3208dc36b --- /dev/null +++ b/apps/api-self-serve/app/root.tsx @@ -0,0 +1,53 @@ +import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router' +import type { Route } from './+types/root' +import './app.css' + +export const links: Route.LinksFunction = () => [] + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ) +} + +export default function App() { + return +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + let message = 'Oops!' + let details = 'An unexpected error occurred.' + let stack: string | undefined + + if (isRouteErrorResponse(error)) { + message = error.status === 404 ? '404' : 'Error' + details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details + } else if (import.meta.env.DEV && error && error instanceof Error) { + details = error.message + stack = error.stack + } + + return ( +
+

{message}

+

{details}

+ {stack && ( +
+          {stack}
+        
+ )} +
+ ) +} diff --git a/apps/api-self-serve/app/routes.ts b/apps/api-self-serve/app/routes.ts new file mode 100644 index 00000000000..10d7044bf99 --- /dev/null +++ b/apps/api-self-serve/app/routes.ts @@ -0,0 +1,3 @@ +import { index, type RouteConfig } from '@react-router/dev/routes' + +export default [index('routes/home.tsx')] satisfies RouteConfig diff --git a/apps/api-self-serve/app/routes/home.tsx b/apps/api-self-serve/app/routes/home.tsx new file mode 100644 index 00000000000..c6316aae628 --- /dev/null +++ b/apps/api-self-serve/app/routes/home.tsx @@ -0,0 +1,11 @@ +import { Welcome } from '~/welcome/welcome' +import type { Route } from './+types/home' + +// biome-ignore lint/correctness/noEmptyPattern: this will likely be updated. this is ootb from the create react router app tool. +export function meta({}: Route.MetaArgs) { + return [{ title: 'New React Router App' }, { name: 'description', content: 'Welcome to React Router!' }] +} + +export default function Home() { + return +} diff --git a/apps/api-self-serve/app/welcome/logo-dark.svg b/apps/api-self-serve/app/welcome/logo-dark.svg new file mode 100644 index 00000000000..dd820289447 --- /dev/null +++ b/apps/api-self-serve/app/welcome/logo-dark.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/api-self-serve/app/welcome/logo-light.svg b/apps/api-self-serve/app/welcome/logo-light.svg new file mode 100644 index 00000000000..73284929d36 --- /dev/null +++ b/apps/api-self-serve/app/welcome/logo-light.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/api-self-serve/app/welcome/welcome.tsx b/apps/api-self-serve/app/welcome/welcome.tsx new file mode 100644 index 00000000000..92555f1574a --- /dev/null +++ b/apps/api-self-serve/app/welcome/welcome.tsx @@ -0,0 +1,80 @@ +/** biome-ignore-all lint/correctness/noRestrictedElements: this will be removed, it's default template */ +import logoDark from './logo-dark.svg' +import logoLight from './logo-light.svg' + +export function Welcome() { + return ( +
+
+
+
+ React Router + React Router +
+
+
+ +
+
+
+ ) +} + +const resources = [ + { + href: 'https://reactrouter.com/docs', + text: 'React Router Docs', + icon: ( + + + + ), + }, + { + href: 'https://rmx.as/discord', + text: 'Join Discord', + icon: ( + + + + ), + }, +] diff --git a/apps/api-self-serve/components.json b/apps/api-self-serve/components.json new file mode 100644 index 00000000000..d0a566ccc19 --- /dev/null +++ b/apps/api-self-serve/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/app.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "~/components", + "utils": "~/lib/utils", + "ui": "~/components/ui", + "lib": "~/lib", + "hooks": "~/hooks" + }, + "registries": {} +} diff --git a/apps/api-self-serve/package.json b/apps/api-self-serve/package.json new file mode 100644 index 00000000000..304a6fe7f27 --- /dev/null +++ b/apps/api-self-serve/package.json @@ -0,0 +1,36 @@ +{ + "name": "api-self-serve", + "private": true, + "type": "module", + "scripts": { + "build": "react-router build", + "dev": "react-router dev", + "start": "react-router-serve ./build/server/index.js", + "typecheck": "react-router typegen && tsc" + }, + "dependencies": { + "@react-router/node": "7.9.4", + "@react-router/serve": "7.6.3", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "isbot": "5.1.31", + "lucide-react": "0.548.0", + "react": "19.0.0", + "react-dom": "19.0.0", + "react-router": "7.12.0", + "tailwind-merge": "3.3.1", + "tailwindcss-animate": "1.0.7" + }, + "devDependencies": { + "@react-router/dev": "7.6.3", + "@tailwindcss/vite": "4.1.13", + "@types/node": "22.13.1", + "@types/react": "19.0.10", + "@uniswap/eslint-config": "workspace:^", + "eslint": "8.57.1", + "tailwindcss": "4.1.16", + "typescript": "5.8.3", + "vite": "npm:rolldown-vite@7.0.10", + "vite-tsconfig-paths": "5.1.4" + } +} diff --git a/apps/api-self-serve/react-router.config.ts b/apps/api-self-serve/react-router.config.ts new file mode 100644 index 00000000000..6ff16f91779 --- /dev/null +++ b/apps/api-self-serve/react-router.config.ts @@ -0,0 +1,7 @@ +import type { Config } from "@react-router/dev/config"; + +export default { + // Config options... + // Server-side render by default, to enable SPA mode set this to `false` + ssr: true, +} satisfies Config; diff --git a/apps/api-self-serve/tailwind.config.ts b/apps/api-self-serve/tailwind.config.ts new file mode 100644 index 00000000000..8135de497d5 --- /dev/null +++ b/apps/api-self-serve/tailwind.config.ts @@ -0,0 +1,430 @@ +import type { Config } from 'tailwindcss' +import tailwindAnimate from 'tailwindcss-animate' + +export default { + darkMode: 'class', + content: [ + './pages/**/*.{js,ts,jsx,tsx,mdx}', + './components/**/*.{js,ts,jsx,tsx,mdx}', + './app/**/*.{js,ts,jsx,tsx,mdx}', + './registry/**/*.{js,ts,jsx,tsx,mdx}', + ], + theme: { + extend: { + fontFamily: { + basel: ['var(--font-basel)', 'sans-serif'], + baselBook: [ + 'Basel Grotesk Book', + '-apple-system', + 'system-ui', + 'BlinkMacSystemFont', + 'Segoe UI', + 'Roboto', + 'Helvetica', + 'Arial', + 'sans-serif', + ], + baselMedium: [ + 'Basel Grotesk Medium', + '-apple-system', + 'system-ui', + 'BlinkMacSystemFont', + 'Segoe UI', + 'Roboto', + 'Helvetica', + 'Arial', + 'sans-serif', + ], + mono: ['InputMono-Regular', 'monospace'], + }, + fontWeight: { + book: '485', + medium: '535', + }, + screens: { + xxs: '360px', + xs: '380px', + sm: '450px', + md: '640px', + lg: '768px', + xl: '1024px', + xxl: '1280px', + xxxl: '1536px', + 'h-short': { raw: '(max-height: 736px)' }, + 'h-mid': { raw: '(max-height: 800px)' }, + }, + fontSize: { + // Headings + 'heading-1': [ + '52px', + { + lineHeight: '60px', + letterSpacing: '-0.02em', + fontWeight: '485', + }, + ], + 'heading-2': [ + '36px', + { + lineHeight: '44px', + letterSpacing: '-0.01em', + fontWeight: '485', + }, + ], + 'heading-3': [ + '24px', + { + lineHeight: '32px', + letterSpacing: '-0.005em', + fontWeight: '485', + }, + ], + // Subheadings + 'subheading-1': [ + '18px', + { + lineHeight: '24px', + fontWeight: '485', + }, + ], + 'subheading-2': [ + '16px', + { + lineHeight: '24px', + fontWeight: '485', + }, + ], + // Body + 'body-1': [ + '18px', + { + lineHeight: '24px', + fontWeight: '485', + }, + ], + 'body-2': [ + '16px', + { + lineHeight: '24px', + fontWeight: '485', + }, + ], + 'body-3': [ + '14px', + { + lineHeight: '20px', + fontWeight: '485', + }, + ], + 'body-4': [ + '12px', + { + lineHeight: '16px', + fontWeight: '485', + }, + ], + // Button Labels + 'button-1': [ + '18px', + { + lineHeight: '24px', + fontWeight: '535', + }, + ], + 'button-2': [ + '16px', + { + lineHeight: '24px', + fontWeight: '535', + }, + ], + 'button-3': [ + '14px', + { + lineHeight: '20px', + fontWeight: '535', + }, + ], + 'button-4': [ + '12px', + { + lineHeight: '16px', + fontWeight: '535', + }, + ], + }, + colors: { + // Base colors + white: '#FFFFFF', + black: '#000000', + + // Semantic colors for light theme + background: { + DEFAULT: '#FFFFFF', // colors.white + dark: '#000000', // colors.black + }, + + // Neutral colors with semantic naming + neutral1: { + DEFAULT: '#222222', // neutral1_light + dark: '#FFFFFF', // neutral1_dark + }, + neutral2: { + DEFAULT: '#7D7D7D', // neutral2_light + dark: '#9B9B9B', // neutral2_dark + }, + neutral3: { + DEFAULT: '#CECECE', // neutral3_light + dark: '#5E5E5E', // neutral3_dark + }, + + // Surface colors with semantic naming + surface1: { + DEFAULT: '#FFFFFF', // surface1_light + dark: '#131313', // surface1_dark + hovered: { + DEFAULT: '#F5F5F5', // surface1_hovered_light + dark: '#181818', // surface1_hovered_dark + }, + }, + surface2: { + DEFAULT: '#F9F9F9', // surface2_light + dark: '#1B1B1B', // surface2_dark + hovered: { + DEFAULT: '#F2F2F2', // surface2_hovered_light + dark: '#242424', // surface2_hovered_dark + }, + }, + surface3: { + DEFAULT: '#22222212', // surface3_light + dark: '#FFFFFF12', // surface3_dark + hovered: { + DEFAULT: 'rgba(34, 34, 34, 0.12)', // surface3_hovered_light + dark: 'rgba(255, 255, 255, 0.16)', // surface3_hovered_dark + }, + }, + surface4: { + DEFAULT: '#FFFFFF64', // surface4_light + dark: '#FFFFFF20', // surface4_dark + }, + surface5: { + DEFAULT: '#00000004', // surface5_light + dark: '#00000004', // surface5_dark + }, + + // Accent colors with semantic naming + accent1: { + DEFAULT: '#FC72FF', // accent1_light + dark: '#FC72FF', // accent1_dark + }, + accent2: { + DEFAULT: '#FFEFFF', // accent2_light + dark: '#311C31', // accent2_dark + }, + accent3: { + DEFAULT: '#4C82FB', // accent3_light + dark: '#4C82FB', // accent3_dark + }, + + // Token colors + token0: { + DEFAULT: '#FC72FF', // token0 in light theme + dark: '#FC72FF', // token0 in dark theme + }, + token1: { + DEFAULT: '#4C82FB', // token1 in light theme + dark: '#4C82FB', // token1 in dark theme + }, + + // Status colors + success: { + DEFAULT: '#40B66B', // success + }, + critical: { + DEFAULT: '#FF5F52', // critical + secondary: { + DEFAULT: '#FFF2F1', // critical2_light + dark: '#2E0805', // critical2_dark + }, + }, + warning: { + DEFAULT: '#EEB317', // gold200 + }, + + // Network colors + network: { + ethereum: '#627EEA', + optimism: '#FF0420', + polygon: '#A457FF', + arbitrum: '#28A0F0', + bsc: '#F0B90B', + base: '#0052FF', + blast: '#FCFC03', + }, + + // Gray palette + gray: { + 50: '#F5F6FC', + 100: '#E8ECFB', + 150: '#D2D9EE', + 200: '#B8C0DC', + 250: '#A6AFCA', + 300: '#98A1C0', + 350: '#888FAB', + 400: '#7780A0', + 450: '#6B7594', + 500: '#5D6785', + 550: '#505A78', + 600: '#404A67', + 650: '#333D59', + 700: '#293249', + 750: '#1B2236', + 800: '#131A2A', + 850: '#0E1524', + 900: '#0D111C', + 950: '#080B11', + }, + + // Pink palette + pink: { + 50: '#F9ECF1', + 100: '#FFD9E4', + 200: '#FBA4C0', + 300: '#FF6FA3', + 400: '#FB118E', + 500: '#C41969', + 600: '#8C0F49', + 700: '#55072A', + 800: '#350318', + 900: '#2B000B', + vibrant: '#F50DB4', + base: '#FC74FE', + }, + + // Red palette + red: { + 50: '#FAECEA', + 100: '#FED5CF', + 200: '#FEA79B', + 300: '#FD766B', + 400: '#FA2B39', + 500: '#C4292F', + 600: '#891E20', + 700: '#530F0F', + 800: '#380A03', + 900: '#240800', + vibrant: '#F14544', + }, + + // Additional color palettes + yellow: { + 50: '#F6F2D5', + 100: '#DBBC19', + 200: '#DBBC19', + 300: '#BB9F13', + 400: '#A08116', + 500: '#866311', + 600: '#5D4204', + 700: '#3E2B04', + 800: '#231902', + 900: '#180F02', + vibrant: '#FAF40A', + }, + + green: { + 50: '#E3F3E6', + 100: '#BFEECA', + 200: '#76D191', + 300: '#40B66B', + 400: '#209853', + 500: '#0B783E', + 600: '#0C522A', + 700: '#053117', + 800: '#091F10', + 900: '#09130B', + vibrant: '#5CFE9D', + }, + + blue: { + 50: '#EDEFF8', + 100: '#DEE1FF', + 200: '#ADBCFF', + 300: '#869EFF', + 400: '#4C82FB', + 500: '#1267D6', + 600: '#1D4294', + 700: '#09265E', + 800: '#0B193F', + 900: '#040E34', + vibrant: '#587BFF', + }, + + gold: { + 200: '#EEB317', + 400: '#B17900', + vibrant: '#FEB239', + }, + + magenta: { + 300: '#FD82FF', + vibrant: '#FC72FF', + }, + + purple: { + 300: '#8440F2', + 900: '#1C0337', + vibrant: '#6100FF', + }, + + // Legacy colors mapping (for compatibility) + border: '#F9F9F9', + input: '#F9F9F9', + ring: '#222222', + foreground: '#222222', + card: { + DEFAULT: '#FFFFFF', + foreground: '#222222', + }, + popover: { + DEFAULT: '#FFFFFF', + foreground: '#222222', + }, + primary: { + DEFAULT: '#222222', + foreground: '#F9F9F9', + }, + secondary: { + DEFAULT: '#F9F9F9', + foreground: '#222222', + }, + muted: { + DEFAULT: '#F9F9F9', + foreground: '#7D7D7D', + }, + destructive: { + DEFAULT: '#FF5F52', + foreground: '#F9F9F9', + }, + scrim: 'rgba(0, 0, 0, 0.60)', + }, + borderRadius: { + none: '0px', + rounded4: '4px', + rounded6: '6px', + rounded8: '8px', + rounded12: '12px', + rounded16: '16px', + rounded20: '20px', + rounded24: '24px', + rounded32: '32px', + roundedFull: '999999px', + }, + boxShadow: { + short: 'var(--shadow-short)', + medium: 'var(--shadow-medium)', + large: 'var(--shadow-large)', + }, + }, + }, + plugins: [tailwindAnimate], +} satisfies Config diff --git a/apps/api-self-serve/tsconfig.eslint.json b/apps/api-self-serve/tsconfig.eslint.json new file mode 100644 index 00000000000..0af7bb26f3f --- /dev/null +++ b/apps/api-self-serve/tsconfig.eslint.json @@ -0,0 +1,5 @@ +// same as tsconfig.json but without references which caused performance issues with typescript-eslint +{ + "extends": "./tsconfig.json", + "references": [] +} diff --git a/apps/api-self-serve/tsconfig.json b/apps/api-self-serve/tsconfig.json new file mode 100644 index 00000000000..a0d80e99890 --- /dev/null +++ b/apps/api-self-serve/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"], + "exclude": ["tools/**/*"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["node", "vite/client"], + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "rootDirs": [".", "./.react-router/types"], + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true + } +} diff --git a/apps/api-self-serve/vite.config.ts b/apps/api-self-serve/vite.config.ts new file mode 100644 index 00000000000..e0925ec7ad1 --- /dev/null +++ b/apps/api-self-serve/vite.config.ts @@ -0,0 +1,15 @@ +import { reactRouter } from '@react-router/dev/vite' +import tailwindcss from '@tailwindcss/vite' +import { defineConfig } from 'vite' +import tsconfigPaths from 'vite-tsconfig-paths' + +export default defineConfig({ + plugins: [ + tailwindcss(), + reactRouter(), + tsconfigPaths({ + // ignores tsconfig files in Nx generator template directories + skip: (dir) => dir.includes('files'), + }), + ], +}) diff --git a/apps/cli/.eslintrc.cjs b/apps/cli/.eslintrc.cjs new file mode 100644 index 00000000000..e2b34a6c0b6 --- /dev/null +++ b/apps/cli/.eslintrc.cjs @@ -0,0 +1,45 @@ +module.exports = { + root: true, + extends: ['@uniswap/eslint-config/native', '@uniswap/eslint-config/webPlatform'], + ignorePatterns: [ + 'node_modules', + '.turbo', + '.eslintrc.js', + 'vitest.config.ts', + 'codegen.ts', + '.nx', + 'scripts', + 'dist', + 'src/**/__generated__', + ], + parserOptions: { + project: 'tsconfig.lint.json', + tsconfigRootDir: __dirname, + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 2018, + sourceType: 'module', + }, + overrides: [ + { + files: ['src/index.ts'], + rules: { + 'check-file/no-index': 'off', + }, + }, + { + files: ['*.ts', '*.tsx'], + rules: { + 'no-relative-import-paths/no-relative-import-paths': [ + 'error', + { + allowSameFolder: false, + prefix: '@universe/cli', + }, + ], + }, + }, + ], + rules: {}, +} diff --git a/apps/cli/README.md b/apps/cli/README.md new file mode 100644 index 00000000000..ce9112633d7 --- /dev/null +++ b/apps/cli/README.md @@ -0,0 +1,2 @@ +# @universe/cli + diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 00000000000..86b22c11c5c --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,38 @@ +{ + "name": "@universe/cli", + "version": "0.0.0", + "type": "module", + "dependencies": { + "@ai-sdk/anthropic": "2.0.41", + "ai": "5.0.87", + "ink": "5.2.1", + "ink-box": "2.0.0", + "ink-select-input": "6.2.0", + "ink-spinner": "5.0.0", + "ink-text-input": "6.0.0", + "react": "19.0.0" + }, + "devDependencies": { + "@types/bun": "1.3.1", + "@types/node": "22.13.1", + "@types/react": "19.0.10", + "@uniswap/eslint-config": "workspace:^", + "depcheck": "1.4.7", + "eslint": "8.57.1", + "ink-gradient": "3.0.0", + "typescript": "5.8.3" + }, + "scripts": { + "dev": "bun run --watch src/cli-ui.tsx", + "lint:biome": "nx lint:biome cli", + "lint:biome:fix": "nx lint:biome:fix cli", + "lint": "nx lint cli", + "lint:fix": "nx lint:fix cli" + }, + "nx": { + "includedScripts": [] + }, + "main": "src/index.ts", + "private": true, + "sideEffects": false +} diff --git a/apps/cli/project.json b/apps/cli/project.json new file mode 100644 index 00000000000..a9dc79ffbd2 --- /dev/null +++ b/apps/cli/project.json @@ -0,0 +1,19 @@ +{ + "name": "@universe/cli", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/cli/src", + "projectType": "application", + "tags": [], + "targets": { + "build": { + "executor": "nx:noop" + }, + "typecheck": {}, + "lint:biome": {}, + "lint:biome:fix": {}, + "lint:eslint": {}, + "lint:eslint:fix": {}, + "lint": {}, + "lint:fix": {} + } +} diff --git a/apps/cli/src/cli-ui.tsx b/apps/cli/src/cli-ui.tsx new file mode 100644 index 00000000000..731955fdc03 --- /dev/null +++ b/apps/cli/src/cli-ui.tsx @@ -0,0 +1,27 @@ +#!/usr/bin/env bun +import { App } from '@universe/cli/src/ui/App' +import { render } from 'ink' +// Ensure React is loaded before ink +import React from 'react' + +export async function runUI(): Promise { + // Check for API key + if (!process.env.ANTHROPIC_API_KEY) { + // eslint-disable-next-line no-console + console.error('Error: ANTHROPIC_API_KEY environment variable is required') + // eslint-disable-next-line no-console + console.error('Get your API key from: https://console.anthropic.com/') + process.exit(1) + } + + render(React.createElement(App)) +} + +// Run if executed directly +if (import.meta.main) { + runUI().catch((error) => { + // eslint-disable-next-line no-console + console.error('Unhandled error:', error) + process.exit(1) + }) +} diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts new file mode 100644 index 00000000000..3ac8f3939b9 --- /dev/null +++ b/apps/cli/src/cli.ts @@ -0,0 +1,429 @@ +#!/usr/bin/env bun +/* eslint-disable complexity */ +import { type CollectOptions } from '@universe/cli/src/core/data-collector' +import { Orchestrator, type OrchestratorConfig, type OutputConfig } from '@universe/cli/src/core/orchestrator' +import { createVercelAIProvider } from '@universe/cli/src/lib/ai-provider-vercel' +import { SqliteCacheProvider } from '@universe/cli/src/lib/cache-provider-sqlite' +import { ConsoleLogger, type Logger } from '@universe/cli/src/lib/logger' +import { parseReleaseIdentifier, ReleaseScanner } from '@universe/cli/src/lib/release-scanner' +import { detectRepository, resolveTeam } from '@universe/cli/src/lib/team-resolver' +import { parseArgs } from 'util' + +/* eslint-disable no-console */ + +// ============================================================================ +// CLI Configuration +// ============================================================================ + +async function main(): Promise { + const { values, positionals } = parseArgs({ + args: Bun.argv, + options: { + // UI options + interactive: { type: 'boolean', default: false, description: 'Force interactive UI mode' }, + ui: { type: 'boolean', default: false, description: 'Force interactive UI mode (alias for --interactive)' }, + + // Analysis options + mode: { + type: 'string', + description: 'Analysis mode (team-digest, changelog, release-changelog, bug-bisect, etc.)', + }, + prompt: { type: 'string', description: 'Custom prompt (file path or inline text)' }, + bug: { type: 'string', description: 'Bug description for bug-bisect mode (requires --release)' }, + + // Data filtering + team: { type: 'string', description: 'Team filter (@org/team or user1,user2)' }, + since: { type: 'string', default: '30 days ago', description: 'Time period to analyze' }, + repo: { type: 'string', description: 'Repository (owner/name)' }, + 'include-open-prs': { type: 'boolean', default: false, description: 'Include open PRs' }, + + // Release options + release: { type: 'string', description: 'Release to analyze (e.g., mobile/1.60 or extension/1.30.0)' }, + 'compare-with': { type: 'string', description: 'Specific version to compare with' }, + 'list-releases': { type: 'boolean', default: false, description: 'List available releases' }, + platform: { type: 'string', description: 'Platform filter (mobile or extension)' }, + + // Commit data options + 'include-diffs': { type: 'boolean', default: false, description: 'Include actual diff content in commits' }, + 'max-diff-size': { type: 'string', default: '100', description: 'Max lines changed per file to include diff' }, + 'max-diff-files': { type: 'string', default: '20', description: 'Max number of files to include diffs for' }, + 'diff-pattern': { type: 'string', description: 'Regex pattern for files to include diffs' }, + 'exclude-test-diffs': { type: 'boolean', default: true, description: 'Exclude test files from diffs' }, + 'token-budget': { type: 'string', default: '50000', description: 'Approximate token budget for commit data' }, + 'pr-body-limit': { type: 'string', default: '2000', description: 'Max characters for PR body' }, + 'save-artifacts': { type: 'boolean', default: false, description: 'Save analysis artifacts for debugging' }, + + // Output options + output: { type: 'string', multiple: true, description: 'Output targets (can specify multiple)' }, + + // Other options + verbose: { type: 'boolean', default: false, description: 'Verbose logging' }, + 'dry-run': { type: 'boolean', default: false, description: 'Test mode without publishing' }, + 'no-cache': { type: 'boolean', default: false, description: 'Bypass cache and fetch fresh data' }, + 'force-refresh': { + type: 'boolean', + default: false, + description: 'Bypass cache and fetch fresh data (alias for --no-cache)', + }, + help: { type: 'boolean', default: false, description: 'Show help' }, + }, + strict: true, + allowPositionals: true, + }) + + if (values.help) { + showHelp() + process.exit(0) + } + + // Detect if we should use UI mode + const shouldUseUI = + values.interactive || + values.ui || + (!values.release && + !values.mode && + !values.prompt && + !values.team && + !values['list-releases'] && + !values.output && + positionals.length === 0) + + if (shouldUseUI) { + throw new Error('UI mode is not supported') + } + + try { + // Create logger early for consistent logging + const logger = new ConsoleLogger(values.verbose || false) + + // Handle --list-releases + if (values['list-releases']) { + const scanner = new ReleaseScanner(process.cwd(), logger) + const platform = values.platform as 'mobile' | 'extension' | undefined + await scanner.listReleases(platform) + process.exit(0) + } + + // Check for API key + if (!process.env.ANTHROPIC_API_KEY) { + logger.error('Error: ANTHROPIC_API_KEY environment variable is required') + logger.error('Get your API key from: https://console.anthropic.com/') + process.exit(1) + } + + // Build configuration + const config = await buildConfig(values, logger) + + if (values.verbose) { + logger.debug(`Configuration: ${JSON.stringify(config, null, 2)}`) + } + + // Create cache provider (unless bypassing cache) + const bypassCache = values['no-cache'] || values['force-refresh'] + const cacheProvider = bypassCache ? undefined : new SqliteCacheProvider() + + // Run orchestrator + const aiProvider = createVercelAIProvider(process.env.ANTHROPIC_API_KEY) + const orchestrator = new Orchestrator({ + config, + aiProvider, + cacheProvider, + logger, + }) + await orchestrator.execute() + + // Close cache connection if used + if (cacheProvider) { + cacheProvider.close() + } + + logger.info('✨ Analysis complete!') + } catch (error) { + // Create a minimal logger if we don't have one yet + const errorLogger = new ConsoleLogger(false) + errorLogger.error(`Fatal error: ${error}`) + process.exit(1) + } +} + +interface BuildConfigArgs { + repo?: string + team?: string + since?: string + 'include-open-prs'?: boolean + release?: string + 'compare-with'?: string + mode?: string + prompt?: string + bug?: string + 'include-diffs'?: boolean + 'max-diff-size'?: string + 'max-diff-files'?: string + 'diff-pattern'?: string + 'exclude-test-diffs'?: boolean + 'token-budget'?: string + 'pr-body-limit'?: string + output?: string[] + verbose?: boolean + 'dry-run'?: boolean + 'save-artifacts'?: boolean + 'no-cache'?: boolean + 'force-refresh'?: boolean +} + +async function buildConfig(args: BuildConfigArgs, logger: Logger): Promise { + // Parse repository + let repository: { owner?: string; name?: string } | undefined + + if (args.repo) { + const match = args.repo.match(/^([^/]+)\/([^/]+)$/) + if (!match) { + throw new Error(`Invalid repository format: "${args.repo}". Expected: owner/repo`) + } + repository = { owner: match[1], name: match[2] } + } else { + // Try to detect from git + repository = (await detectRepository()) || undefined + } + + if (repository) { + logger.info(`Repository: ${repository.owner}/${repository.name}`) + } + + // Resolve team if specified + let teamFilter: string[] | undefined + let teamUsernames: string[] | undefined + + if (args.team) { + logger.info(`Resolving team: ${args.team}`) + const resolution = await resolveTeam(args.team) + teamFilter = resolution.emails + teamUsernames = resolution.usernames + + if (teamFilter.length === 0) { + throw new Error('Failed to resolve team filter') + } + logger.info(`Team resolved to ${teamFilter.length} email(s)`) + if (args.verbose) { + logger.debug(`Emails: ${teamFilter}`) + logger.debug(`Usernames: ${teamUsernames}`) + } + } + + // Parse outputs + const outputs = parseOutputs(args.output || ['stdout']) + + // Handle release mode + let releaseOptions + if (args.release) { + const releaseId = parseReleaseIdentifier(args.release) + if (!releaseId) { + throw new Error(`Invalid release format: "${args.release}". Expected: mobile/1.60 or extension/1.30.0`) + } + + let version = releaseId.version + + // Handle "latest" keyword + if (version === 'latest') { + const scanner = new ReleaseScanner(process.cwd(), logger) + const latestRelease = await scanner.getLatestRelease(releaseId.platform) + + if (!latestRelease) { + throw new Error(`No releases found for platform: ${releaseId.platform}`) + } + + version = latestRelease.version + logger.info(`Using latest ${releaseId.platform} release: ${version}`) + } + + releaseOptions = { + platform: releaseId.platform, + version, + compareWith: args['compare-with'], + } + + // Auto-set mode to release-changelog if not explicitly set + if (!args.mode) { + args.mode = 'release-changelog' + } + } + + // Build commit data config + const commitDataConfig = { + includeFilePaths: true, // Always include file paths + includeDiffs: args['include-diffs'], + maxDiffSize: parseInt(args['max-diff-size'] || '100', 10), + maxDiffFiles: parseInt(args['max-diff-files'] || '20', 10), + diffFilePattern: args['diff-pattern'], + excludeTestFiles: args['exclude-test-diffs'] !== false, + tokenBudget: parseInt(args['token-budget'] || '50000', 10), + prBodyLimit: parseInt(args['pr-body-limit'] || '2000', 10), + } + + // Build collection options + const collectOptions: CollectOptions = { + since: args.since ?? '30 days ago', + repository, + teamFilter, + teamUsernames, + includeOpenPrs: args['include-open-prs'], + commitDataConfig, + } + + // Handle bug-bisect mode + if (args.bug) { + if (!args.release) { + throw new Error('--bug requires --release to be specified') + } + // Auto-set mode to bug-bisect if not explicitly set + if (!args.mode) { + args.mode = 'bug-bisect' + } else if (args.mode !== 'bug-bisect') { + throw new Error('--bug can only be used with --mode bug-bisect') + } + } + + // Build analysis config + const analysisConfig = { + mode: args.mode, + prompt: args.prompt, + releaseOptions, + variables: args.bug + ? { + BUG_DESCRIPTION: args.bug, + } + : undefined, + } + + return { + analysis: analysisConfig, + outputs, + collect: collectOptions, + verbose: args.verbose, + dryRun: args['dry-run'], + saveArtifacts: args['save-artifacts'], + bypassCache: args['no-cache'] || args['force-refresh'] || false, + } +} + +function parseOutputs(outputs: string[]): OutputConfig[] { + return outputs.map((output) => { + // Parse format: type:target + // Examples: + // stdout + // file:report.md + // slack:#channel + // github-release + + const parts = output.split(':') + const type = parts[0] + const target = parts.slice(1).join(':') // Handle colons in target + + if (!type) { + throw new Error(`Invalid output format: "${output}"`) + } + + return { + type, + target: target || undefined, + } + }) +} + +function showHelp(): void { + console.log(` +Repository Intelligence System - Analyze git history with AI + +Usage: bun scripts/gh-agent-refactored.ts [options] + +ANALYSIS OPTIONS: + --mode Predefined analysis mode (team-digest, changelog, release-changelog, bug-bisect) + --prompt Custom prompt (file path or inline text) + Examples: + --prompt ./my-analysis.md + --prompt "Analyze for security issues" + --bug Bug description for bug-bisect mode (requires --release) + Example: + --bug "Users can't connect wallet on mobile app" + --release mobile/1.60 --bug "Crash on launch" + +DATA FILTERING: + --team Team filter (@org/team or user1,user2) + Examples: + --team @Uniswap/apps-swap + --team alice,bob + --team @Uniswap/backend,external-contributor + --since Time period to analyze (default: "30 days ago") + --repo Repository to analyze (auto-detected if not specified) + --include-open-prs Include open/in-review PRs in analysis + +RELEASE OPTIONS: + --release Release to analyze (e.g., mobile/1.60, extension/1.30.0, or mobile/latest) + --compare-with Specific version to compare with (auto-detects if not specified) + --list-releases List available releases + --platform Platform filter for --list-releases (mobile or extension) + +OUTPUT OPTIONS: + --output Output target (can specify multiple) + Examples: + --output stdout (default) + --output file:report.md + --output slack:#channel + Multiple outputs: + --output file:report.md --output slack:#updates + +OTHER OPTIONS: + --verbose Enable verbose logging + --dry-run Test mode without publishing + --no-cache Bypass cache and fetch fresh data + --force-refresh Bypass cache and fetch fresh data (alias for --no-cache) + --help Show this help message + +EXAMPLES: + # Team digest with default settings + bun scripts/gh-agent-refactored.ts --mode team-digest --team @Uniswap/apps-swap + + # Weekly changelog + bun scripts/gh-agent-refactored.ts --mode changelog --since "1 week ago" + + # Release changelog for mobile + bun scripts/gh-agent-refactored.ts --release mobile/1.60 + + # Release changelog for latest mobile release + bun scripts/gh-agent-refactored.ts --release mobile/latest + + # Release changelog with specific comparison + bun scripts/gh-agent-refactored.ts --release mobile/1.60 --compare-with mobile/1.58 + + # Bug bisect - find which commit introduced a bug + bun scripts/gh-agent-refactored.ts --release mobile/1.60 --bug "Users can't connect wallet" + + # List all releases + bun scripts/gh-agent-refactored.ts --list-releases + + # List mobile releases only + bun scripts/gh-agent-refactored.ts --list-releases --platform mobile + + # Custom analysis with multiple outputs + bun scripts/gh-agent-refactored.ts \\ + --prompt "Analyze for performance improvements" \\ + --team alice,bob \\ + --output file:performance.md \\ + --output slack:#perf-updates + +ENVIRONMENT VARIABLES: + ANTHROPIC_API_KEY Required - Your Anthropic API key + SLACK_WEBHOOK Optional - Webhook URL for Slack integration + +For more information, see the documentation at: +https://github.com/Uniswap/universe/scripts/gh-agent/README.md +`) +} + +// Run if executed directly +if (import.meta.main) { + main().catch((error) => { + console.error('Unhandled error:', error) + process.exit(1) + }) +} diff --git a/apps/cli/src/core/data-collector.ts b/apps/cli/src/core/data-collector.ts new file mode 100644 index 00000000000..fd9979b9cc6 --- /dev/null +++ b/apps/cli/src/core/data-collector.ts @@ -0,0 +1,1096 @@ +/* eslint-disable max-lines */ +/* eslint-disable max-depth */ +/* eslint-disable complexity */ +/* eslint-disable max-params */ + +/** biome-ignore-all lint/suspicious/noConsole: CLI tool requires console output */ + +import { getCommitsCacheKey, getPullRequestsCacheKey, getStatsCacheKey } from '@universe/cli/src/lib/cache-keys' +import { type CacheProvider } from '@universe/cli/src/lib/cache-provider' +import type { Logger } from '@universe/cli/src/lib/logger' +import { cleanPRBody } from '@universe/cli/src/lib/pr-body-cleaner' +import { type ReleaseComparison } from '@universe/cli/src/lib/release-scanner' +import { isTrivialFile } from '@universe/cli/src/lib/trivial-files' +import { $ } from 'bun' + +// ============================================================================ +// Data Collection Types +// ============================================================================ + +export interface CommitDataConfig { + includeFilePaths?: boolean // Include file paths in commit data (default: true) + includeDiffs?: boolean // Include actual diff content (default: false) + maxDiffSize?: number // Max lines changed per file to include diff (default: 100) + maxDiffFiles?: number // Max number of files to include diffs for (default: 20) + diffFilePattern?: string // Regex pattern for files to include diffs (default: \.(ts|tsx|js|jsx)$) + excludeTestFiles?: boolean // Exclude test files from diffs (default: true) + tokenBudget?: number // Approximate token budget for all commit data (default: 50000) + prBodyLimit?: number // Max characters for PR body (default: 2000) + cleanPRBodies?: boolean // Enable PR body cleaning (default: true) +} + +export interface CollectOptions { + since: string + branch?: string + author?: string + repoPath?: string + includeOpenPrs?: boolean + teamFilter?: string[] + teamUsernames?: string[] + repository?: { owner?: string; name?: string } + releaseComparison?: ReleaseComparison + excludeTrivialCommits?: boolean // Filter out commits with only lockfile/snapshot changes + commitDataConfig?: CommitDataConfig // Configuration for commit data collection +} + +export interface RepositoryData { + commits: Commit[] + pullRequests: PullRequest[] + stats: StatsOutput + metadata: { + repository: string + period: string + collectedAt: Date + commitCount: number + prCount: number + releaseInfo?: { + from: string + to: string + platform: 'mobile' | 'extension' + } + filtering?: { + totalCommitsFound: number + trivialCommitsFiltered: number + filesProcessed: number + trivialFilesSkipped: number + } + } +} + +export interface Commit { + sha: string + author: { name: string; email: string } + timestamp: Date + message: string + stats: { filesChanged: number; insertions: number; deletions: number } + files?: { + path: string + status: 'added' | 'modified' | 'deleted' | 'renamed' + additions: number + deletions: number + diff?: string // Optional: actual diff content + diffTruncated?: boolean // Optional: indicates if diff was truncated + }[] +} + +export interface PullRequest { + number: number + title: string + body: string + author: string + state: 'open' | 'closed' + mergedAt: string + mergeCommitSha?: string +} + +export interface StatsOutput { + totalCommits: number + totalAuthors: number + filesChanged: number + linesAdded: number + linesDeleted: number +} + +// ============================================================================ +// Helper Functions for Git Output Parsing +// ============================================================================ + +/** + * Validates and parses a git log line in the format: sha|email|name|timestamp|message + * Returns undefined if the line is malformed + */ +function parseGitLogLine( + line: string, +): { sha: string; email: string; name: string; timestamp: string; message: string } | undefined { + const parts = line.split('|') + if (parts.length < 5) { + return undefined + } + + const sha = parts[0]?.trim() + const email = parts[1]?.trim() + const name = parts[2]?.trim() + const timestamp = parts[3]?.trim() + const messageParts = parts.slice(4) + const message = messageParts.join('|') + + if (!sha || !email || !name || !timestamp) { + return undefined + } + + return { sha, email, name, timestamp, message } +} + +/** + * Validates and parses a git numstat line in the format: additions\tdeletions\tfilepath + * Returns undefined if the line is malformed + */ +function parseNumstatLine(line: string): { additions: string; deletions: string; filepath: string } | undefined { + const parts = line.split('\t') + if (parts.length < 3) { + return undefined + } + + const additions = parts[0]?.trim() + const deletions = parts[1]?.trim() + const filepath = parts.slice(2).join('\t') // Handle filepaths with tabs + + if (additions === undefined || deletions === undefined || !filepath) { + return undefined + } + + return { additions, deletions, filepath } +} + +// ============================================================================ +// Data Collector +// ============================================================================ + +export class DataCollector { + private filteringStats = { + totalCommitsFound: 0, + trivialCommitsFiltered: 0, + filesProcessed: 0, + trivialFilesSkipped: 0, + } + private tokenUsage = 0 + private diffsCollected = 0 + + constructor( + private repoPath: string = process.cwd(), + private cacheProvider?: CacheProvider, + private bypassCache: boolean = false, + private logger?: Logger, + ) {} + + /** + * Estimate tokens for a given text (rough approximation: 1 token ≈ 4 characters) + */ + private estimateTokens(text: string): number { + return Math.ceil(text.length / 4) + } + + /** + * Check if a file should have its diff included based on config + */ + private shouldIncludeDiff(filePath: string, additions: number, deletions: number, config: CommitDataConfig): boolean { + // Check if diffs are enabled + if (!config.includeDiffs) { + return false + } + + // Check if we've hit the diff file limit + if (this.diffsCollected >= (config.maxDiffFiles || 20)) { + return false + } + + // Check if we're within token budget + const budget = config.tokenBudget || 50000 // Can be higher with compact format + if (this.tokenUsage >= budget) { + this.logger?.info(`Token budget reached (${this.tokenUsage}/${budget}), skipping remaining diffs`) + return false + } + + // Check file size limit + const totalChanges = additions + deletions + if (totalChanges > (config.maxDiffSize || 100)) { + return false + } + + // Check if it's a test file and we're excluding them + if (config.excludeTestFiles !== false) { + const testPatterns = [ + /\.test\.(ts|tsx|js|jsx)$/, + /\.spec\.(ts|tsx|js|jsx)$/, + /__tests__\//, + /test\//i, + /tests\//i, + /e2e\//, + ] + if (testPatterns.some((p) => p.test(filePath))) { + return false + } + } + + // Check file pattern if provided + if (config.diffFilePattern) { + // eslint-disable-next-line security/detect-non-literal-regexp -- User-provided pattern from config, used for file filtering + const pattern = new RegExp(config.diffFilePattern) + if (!pattern.test(filePath)) { + return false + } + } else { + // Default pattern: TypeScript and JavaScript files + const defaultPattern = /\.(ts|tsx|js|jsx)$/ + if (!defaultPattern.test(filePath)) { + return false + } + } + + return true + } + + /** + * Collect diff for a specific file in a commit + */ + private async collectFileDiff(sha: string, filePath: string, config: CommitDataConfig): Promise { + try { + // Get just the unified diff for this file (more compact than full git show) + const diff = await $`git -C ${this.repoPath} diff ${sha}^..${sha} -- ${filePath}`.text() + + if (!diff || diff.trim().length === 0) { + // File might be new, try different approach + const showDiff = await $`git -C ${this.repoPath} show ${sha} --format= -- ${filePath}`.text() + if (!showDiff) { + return undefined + } + + // For new files, just get the content preview + const lines = showDiff.split('\n') + const maxSize = Math.min(config.maxDiffSize || 100, 30) // Smaller preview for new files + if (lines.length > maxSize) { + return lines.slice(0, maxSize).join('\n') + `\n... [+${lines.length - maxSize} more lines]` + } + return showDiff + } + + // Extract just the hunks (skip file headers for compactness) + const lines = diff.split('\n') + const hunkLines = lines.filter( + (line: string) => line.startsWith('@@') || line.startsWith('+') || line.startsWith('-') || line.startsWith(' '), + ) + + // Check size and truncate if needed + const maxSize = config.maxDiffSize || 100 + if (hunkLines.length > maxSize) { + const truncated = hunkLines.slice(0, maxSize) + truncated.push(`... [${hunkLines.length - maxSize} more lines]`) + return truncated.join('\n') + } + + return hunkLines.join('\n') + } catch (_error) { + // Might fail for new files or first commit, that's ok + return undefined + } + } + + /** + * Restore Date objects from cached JSON (JSON.parse converts dates to strings) + */ + private restoreDatesFromCache(items: T[]): T[] { + return items.map((item) => { + if (item.timestamp && typeof item.timestamp === 'string') { + return { ...item, timestamp: new Date(item.timestamp) } + } + return item + }) + } + + async collect(options: CollectOptions): Promise { + this.logger?.info('Collecting repository data...') + + // Reset filtering stats + this.filteringStats = { + totalCommitsFound: 0, + trivialCommitsFiltered: 0, + filesProcessed: 0, + trivialFilesSkipped: 0, + } + this.tokenUsage = 0 + this.diffsCollected = 0 + + // Try to get commits from cache + let allCommits: Commit[] | null = null + if (!this.bypassCache && this.cacheProvider) { + const cacheKey = getCommitsCacheKey(options) + const cached = await this.cacheProvider.get(cacheKey) + if (cached) { + allCommits = this.restoreDatesFromCache(cached) + this.logger?.info(`Cache hit: Found ${allCommits.length} commits in cache`) + } + } + + // Fetch commits if not cached + if (!allCommits) { + if (options.releaseComparison) { + this.logger?.info(`Fetching commits for release: ${options.releaseComparison.commitRange}`) + allCommits = await this.getReleaseCommits(options.releaseComparison, options) + } else { + this.logger?.info('Fetching commits from git log...') + allCommits = await this.getCommits(options) + } + + // Store in cache (release comparisons are deterministic, cache indefinitely) + if (this.cacheProvider) { + const cacheKey = getCommitsCacheKey(options) + const ttl = options.releaseComparison ? undefined : 3600 // 1 hour for time-based queries + await this.cacheProvider.set(cacheKey, allCommits, ttl) + this.logger?.info(`Cached ${allCommits.length} commits`) + } + } + + // Filter by team if specified + const commits = options.teamFilter?.length ? this.filterCommitsByTeam(allCommits, options.teamFilter) : allCommits + + this.logger?.info(`Found ${commits.length} commits (${allCommits.length} total)`) + + // Try to get PRs from cache + let pullRequests: PullRequest[] | null = null + if (!this.bypassCache && this.cacheProvider) { + const cacheKey = getPullRequestsCacheKey(options) + const cached = await this.cacheProvider.get(cacheKey) + if (cached) { + pullRequests = cached + this.logger?.info(`Cache hit: Found ${pullRequests.length} PRs in cache`) + } + } + + // Fetch PRs if not cached + if (!pullRequests) { + this.logger?.info('Fetching pull requests from GitHub...') + pullRequests = await this.getPullRequests(options) + + // Store in cache + if (this.cacheProvider) { + const cacheKey = getPullRequestsCacheKey(options) + const ttl = options.releaseComparison ? undefined : 3600 // 1 hour for time-based queries + await this.cacheProvider.set(cacheKey, pullRequests, ttl) + this.logger?.info(`Cached ${pullRequests.length} PRs`) + } + } + + this.logger?.info(`Found ${pullRequests.length} pull requests`) + + // Try to get stats from cache + let stats: StatsOutput | null = null + if (!this.bypassCache && this.cacheProvider) { + const cacheKey = getStatsCacheKey(options) + const cached = await this.cacheProvider.get(cacheKey) + if (cached) { + stats = cached + this.logger?.info('Cache hit: Found stats in cache') + } + } + + // Calculate stats if not cached + if (!stats) { + stats = await this.getStats(options) + + // Store in cache + if (this.cacheProvider) { + const cacheKey = getStatsCacheKey(options) + const ttl = options.releaseComparison ? undefined : 3600 // 1 hour for time-based queries + await this.cacheProvider.set(cacheKey, stats, ttl) + this.logger?.info('Cached stats') + } + } + + // Build metadata + const repoName = + options.repository?.owner && options.repository.name + ? `${options.repository.owner}/${options.repository.name}` + : this.repoPath + + const metadata: RepositoryData['metadata'] = { + repository: repoName, + period: options.since, + collectedAt: new Date(), + commitCount: commits.length, + prCount: pullRequests.length, + } + + // Add release info if available + if (options.releaseComparison) { + metadata.releaseInfo = { + from: options.releaseComparison.from.version, + to: options.releaseComparison.to.version, + platform: options.releaseComparison.to.platform, + } + } + + // Add filtering stats if we have them + if (this.filteringStats.totalCommitsFound > 0) { + metadata.filtering = { + totalCommitsFound: this.filteringStats.totalCommitsFound, + trivialCommitsFiltered: this.filteringStats.trivialCommitsFiltered, + filesProcessed: this.filteringStats.filesProcessed, + trivialFilesSkipped: this.filteringStats.trivialFilesSkipped, + } + } + + return { + commits, + pullRequests, + stats, + metadata, + } + } + + private async getCommits(options: CollectOptions): Promise { + const format = '%H|%ae|%an|%at|%s' + const result = await $`git -C ${this.repoPath} log --since="${options.since}" --format="${format}" --numstat`.text() + + const commits: Commit[] = [] + const lines = result.split('\n') + let skippedTrivialCommits = 0 + let totalCommitsProcessed = 0 + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (!line || !line.includes('|')) { + continue + } + + // Parse the commit line + const parsed = parseGitLogLine(line) + if (!parsed) { + this.logger?.warn(`Skipping malformed git log line: ${line}`) + continue + } + + totalCommitsProcessed++ + const { sha, email, name, timestamp, message } = parsed + const stats = { filesChanged: 0, insertions: 0, deletions: 0 } + const fileChanges: Commit['files'] = [] + + // Parse numstat + i++ + while (i < lines.length) { + const currentLine = lines[i] + if (!currentLine || currentLine.includes('|')) { + break + } + const statLine = currentLine.trim() + if (statLine) { + const numstat = parseNumstatLine(statLine) + if (numstat) { + const { additions, deletions, filepath } = numstat + + // Skip trivial files when in release mode + if (options.releaseComparison && isTrivialFile(filepath)) { + i++ + continue + } + + const adds = additions === '-' ? 0 : parseInt(additions, 10) || 0 + const dels = deletions === '-' ? 0 : parseInt(deletions, 10) || 0 + + // Determine file status + let status: 'added' | 'modified' | 'deleted' | 'renamed' = 'modified' + if (adds > 0 && dels === 0) { + status = 'added' + } else if (adds === 0 && dels > 0) { + status = 'deleted' + } else if (additions === '-' || deletions === '-') { + status = 'modified' + } + + fileChanges.push({ + path: filepath, + status, + additions: adds, + deletions: dels, + }) + + stats.insertions += adds + stats.deletions += dels + stats.filesChanged++ + } + } + i++ + } + i-- // Back up one since the outer loop will increment + + // Skip commits that only touch trivial files (only in release mode) + if (options.releaseComparison && stats.filesChanged === 0) { + skippedTrivialCommits++ + continue + } + + const commit = { + sha, + author: { name, email }, + timestamp: new Date(parseInt(timestamp, 10) * 1000), + message, + stats, + files: fileChanges, + } + + // Collect diffs if configured + if (options.commitDataConfig?.includeDiffs && fileChanges.length > 0) { + for (const file of fileChanges) { + if (this.shouldIncludeDiff(file.path, file.additions, file.deletions, options.commitDataConfig)) { + const diff = await this.collectFileDiff(sha, file.path, options.commitDataConfig) + if (diff) { + file.diff = diff + file.diffTruncated = diff.includes('[diff truncated') + this.diffsCollected++ + this.tokenUsage += this.estimateTokens(diff) + + // Log progress + if (this.diffsCollected % 5 === 0) { + this.logger?.info(`Collected ${this.diffsCollected} diffs (${this.tokenUsage} tokens used)`) + } + } + } + } + } + + commits.push(commit) + } + + // Store stats for reporting (if in release mode) + if (totalCommitsProcessed > 0) { + this.filteringStats.totalCommitsFound += totalCommitsProcessed + this.filteringStats.trivialCommitsFiltered += skippedTrivialCommits + } + + if (skippedTrivialCommits > 0) { + this.logger?.info(`Filtered out ${skippedTrivialCommits} commits with only trivial file changes`) + } + + return commits + } + + private filterCommitsByTeam(commits: Commit[], teamFilter: string[]): Commit[] { + return commits.filter((c) => { + const matches = teamFilter.includes(c.author.email) + if (!matches) { + this.logger?.debug(`Filtering out commit ${c.sha.slice(0, 7)} by ${c.author.email}`) + } + return matches + }) + } + + private async getPullRequests(options: CollectOptions): Promise { + if (!options.repository?.owner || !options.repository.name) { + this.logger?.debug('No repository configured, skipping PR fetch') + return [] + } + + const repository = `${options.repository.owner}/${options.repository.name}` + + try { + // For release comparisons, get PR numbers from the commit range + if (options.releaseComparison) { + return await this.getReleasePullRequests(options, repository) + } + + // Parse the 'since' date for time-based analysis + const sinceDate = this.parseSinceDate(options.since) + const sinceISO = sinceDate.toISOString().split('T')[0] ?? sinceDate.toISOString() + + // Build search query + const authorFilter = options.teamUsernames?.length + ? options.teamUsernames.map((author) => `author:${author}`).join(' ') + : '' + + const query = options.includeOpenPrs + ? `repo:${repository} is:pr created:>=${sinceISO} ${authorFilter}` + : `repo:${repository} is:pr is:closed closed:>=${sinceISO} ${authorFilter}` + + this.logger?.debug(`GitHub Search Query: ${query}`) + + // Get PR numbers from main branch commits for filtering + const prNumbersInMain = await this.getPRNumbersFromMainBranch(sinceISO) + + // Fetch PRs from GitHub + const allPRs: PullRequest[] = [] + let page = 1 + const perPage = 100 + const maxPages = 10 + + while (page <= maxPages) { + const apiPath = `/search/issues?q=${encodeURIComponent(query)}&per_page=${perPage}&page=${page}` + const searchResult = await $`gh api ${apiPath}`.text() + const searchData = JSON.parse(searchResult) + + if (!searchData.items || searchData.items.length === 0) { + break + } + + for (const pr of searchData.items) { + // Filter to only include PRs whose commits are in main (unless open) + if (pr.state === 'open' || prNumbersInMain.has(pr.number)) { + const limit = options.commitDataConfig?.prBodyLimit || 2000 + const shouldClean = options.commitDataConfig?.cleanPRBodies !== false // Default to true + let body = pr.body ? pr.body : '' + + // Clean PR body if enabled (default: true) + if (body && shouldClean) { + body = cleanPRBody(body) + } + + // Apply character limit after cleaning + body = body.slice(0, limit) + + allPRs.push({ + number: pr.number, + title: pr.title, + body, + author: pr.user?.login || 'unknown', + state: pr.state as 'open' | 'closed', + mergedAt: pr.closed_at, + mergeCommitSha: pr.pull_request?.merge_commit_sha, + }) + } + } + + if (searchData.items.length < perPage) { + break + } + page++ + } + + return allPRs + } catch (error) { + this.logger?.error(`GitHub PR fetch failed: ${error}`) + return [] + } + } + + private parseSinceDate(since: string): Date { + const sinceDate = new Date() + const sinceMatch = since.match(/(\d+)\s+(day|week|month|year)s?\s+ago/) + + if (sinceMatch?.[1] && sinceMatch[2]) { + const amount = sinceMatch[1] + const unit = sinceMatch[2] + const num = parseInt(amount, 10) + + switch (unit) { + case 'day': + sinceDate.setDate(sinceDate.getDate() - num) + break + case 'week': + sinceDate.setDate(sinceDate.getDate() - num * 7) + break + case 'month': + sinceDate.setMonth(sinceDate.getMonth() - num) + break + case 'year': + sinceDate.setFullYear(sinceDate.getFullYear() - num) + break + } + } + + return sinceDate + } + + private async getPRNumbersFromMainBranch(sinceISO: string): Promise> { + const allCommitMessages = await $`git log main --since="${sinceISO}" --format="%s"`.text() + const prNumbersInMain = new Set() + const prRegex = /#(\d+)/g + + for (const match of allCommitMessages.matchAll(prRegex)) { + if (match[1]) { + const prNum = parseInt(match[1], 10) + if (prNum) { + prNumbersInMain.add(prNum) + } + } + } + + this.logger?.debug(`Found ${prNumbersInMain.size} unique PR numbers in main branch commits`) + return prNumbersInMain + } + + private async getReleasePullRequests(options: CollectOptions, repository: string): Promise { + if (!options.releaseComparison) { + return [] + } + + const range = options.releaseComparison.commitRange + this.logger?.info('Extracting PR information from commits...') + + // Extract PR numbers and titles from commit messages + const commits = await $`git -C ${this.repoPath} log ${range} --format="%H|%s|%ae|%an"`.text() + const pullRequests: PullRequest[] = [] + const seenPRs = new Set() + + for (const line of commits.split('\n')) { + if (!line) { + continue + } + const parts = line.split('|') + const sha = parts[0] + const message = parts[1] + const author = parts[3] + + if (!sha || !message || !author) { + continue + } + + // Look for PR number in commit message (e.g., "(#1234)" or "PR #1234") + const prMatch = message.match(/#(\d+)/) + if (prMatch?.[1]) { + const prNumber = parseInt(prMatch[1], 10) + + // Skip if we've already seen this PR + if (seenPRs.has(prNumber)) { + continue + } + seenPRs.add(prNumber) + + // Extract PR title from commit message (usually after the PR number) + let title = message + // Remove PR number patterns + title = title + .replace(/\(#\d+\)/, '') + .replace(/#\d+/, '') + .trim() + + // Create a minimal PR object from commit data + pullRequests.push({ + number: prNumber, + title: title || `PR #${prNumber}`, + body: '', // We don't have the body without API call + author: author || 'unknown', + state: 'closed', // Assume closed if in release + mergedAt: '', // We don't have exact merge time + mergeCommitSha: sha, + }) + } + } + + this.logger?.info(`Found ${pullRequests.length} PRs from commit messages`) + + // Fetch detailed PR info for all PRs using gh api with parallel batching + if (pullRequests.length > 0) { + this.logger?.info(`Fetching detailed info for ${pullRequests.length} PRs...`) + + const limit = options.commitDataConfig?.prBodyLimit || 2000 + const CONCURRENCY_LIMIT = 15 // Fetch 15 PRs in parallel at a time + let fetchedCount = 0 + + // Helper function to fetch a single PR + const fetchPR = async (pr: PullRequest): Promise => { + try { + // Use jq to output a JSON object to properly handle multi-line PR bodies + const prResultJson = + await $`gh api repos/${repository}/pulls/${pr.number} --jq '{title: .title, body: .body, author: .user.login, mergedAt: .merged_at}'`.text() + const prData = JSON.parse(prResultJson) + + // Update with real data + if (prData.title) { + pr.title = prData.title + } + if (prData.body && prData.body !== 'null') { + const shouldClean = options.commitDataConfig?.cleanPRBodies !== false // Default to true + let body = prData.body + + // Clean PR body if enabled (default: true) + if (shouldClean) { + body = cleanPRBody(body) + } + + // Apply character limit after cleaning + pr.body = body.slice(0, limit) + } + if (prData.author) { + pr.author = prData.author + } + if (prData.mergedAt && prData.mergedAt !== 'null') { + pr.mergedAt = prData.mergedAt + } + } catch (_error) { + // Keep the minimal data we already have + this.logger?.warn(`Failed to fetch PR #${pr.number}, continuing with minimal data`) + } + } + + // Process PRs in batches for parallel fetching + for (let i = 0; i < pullRequests.length; i += CONCURRENCY_LIMIT) { + const batch = pullRequests.slice(i, i + CONCURRENCY_LIMIT) + const batchNumber = Math.floor(i / CONCURRENCY_LIMIT) + 1 + const totalBatches = Math.ceil(pullRequests.length / CONCURRENCY_LIMIT) + + // Fetch all PRs in this batch in parallel + await Promise.all(batch.map((pr) => fetchPR(pr))) + + fetchedCount += batch.length + + // Log progress after each batch + this.logger?.info( + `Fetched ${fetchedCount}/${pullRequests.length} PRs (batch ${batchNumber}/${totalBatches})...`, + ) + + // Add small delay between batches to respect rate limits (except for last batch) + if (i + CONCURRENCY_LIMIT < pullRequests.length) { + await new Promise((resolve) => setTimeout(resolve, 200)) + } + } + + this.logger?.info(`Successfully fetched detailed info for ${fetchedCount}/${pullRequests.length} PRs`) + } + + return pullRequests + } + + private async getReleaseCommits(comparison: ReleaseComparison, options: CollectOptions): Promise { + const format = '%H|%ae|%an|%at|%s' + const range = comparison.commitRange + + this.logger?.info(`Getting commits for release: ${range}`) + + const result = await $`git -C ${this.repoPath} log ${range} --format="${format}" --numstat`.text() + + const commits: Commit[] = [] + const lines = result.split('\n') + let skippedTrivialCommits = 0 + let totalCommitsProcessed = 0 + let totalFilesProcessed = 0 + let trivialFilesSkipped = 0 + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (!line || !line.includes('|')) { + continue + } + + // Parse the commit line + const parsed = parseGitLogLine(line) + if (!parsed) { + this.logger?.warn(`Skipping malformed git log line: ${line}`) + continue + } + + totalCommitsProcessed++ + const { sha, email, name, timestamp, message } = parsed + const stats = { filesChanged: 0, insertions: 0, deletions: 0 } + const fileChanges: Commit['files'] = [] + const trivialFiles: string[] = [] + + // Parse numstat + i++ + // Skip empty line after commit header + if (i < lines.length && lines[i] === '') { + i++ + } + + while (i < lines.length) { + const statLine = lines[i] + + // Stop if this is the start of a new commit (contains |) or we hit another empty line followed by a commit + if (!statLine || statLine.includes('|')) { + if (!statLine) { + // Check if next line is a commit + const nextLine = lines[i + 1] + if (nextLine?.includes('|')) { + break + } + // Otherwise it's just an empty line in the numstat, skip it + i++ + continue + } else { + // New commit line, back up for outer loop + i-- + break + } + } + + if (statLine.trim()) { + // Git numstat format is: additions\tdeletions\tfilepath + const tabIndex1 = statLine.indexOf('\t') + const tabIndex2 = statLine.indexOf('\t', tabIndex1 + 1) + + if (tabIndex1 > -1 && tabIndex2 > -1) { + const additions = statLine.substring(0, tabIndex1) + const deletions = statLine.substring(tabIndex1 + 1, tabIndex2) + const filepath = statLine.substring(tabIndex2 + 1) + + // Skip empty filepaths + if (!filepath) { + i++ + continue + } + + totalFilesProcessed++ + + // Skip trivial files + if (!isTrivialFile(filepath)) { + const adds = additions === '-' ? 0 : parseInt(additions, 10) || 0 + const dels = deletions === '-' ? 0 : parseInt(deletions, 10) || 0 + + // Determine file status based on additions/deletions + let status: 'added' | 'modified' | 'deleted' | 'renamed' = 'modified' + if (adds > 0 && dels === 0) { + status = 'added' + } else if (adds === 0 && dels > 0) { + status = 'deleted' + } else if (additions === '-' || deletions === '-') { + // Binary files or renames show '-' for stats + status = 'modified' + } + + fileChanges.push({ + path: filepath, + status, + additions: adds, + deletions: dels, + }) + + stats.insertions += adds + stats.deletions += dels + stats.filesChanged++ + } else { + trivialFiles.push(filepath) + trivialFilesSkipped++ + } + } + } + i++ + } + i-- // Back up one since the outer loop will increment + + // Skip commits that only touch trivial files + if (stats.filesChanged === 0 && trivialFiles.length > 0) { + skippedTrivialCommits++ + continue + } else if (stats.filesChanged === 0 && trivialFiles.length === 0) { + // Empty commit or merge commit, skip + continue + } + + const commit = { + sha, + author: { name, email }, + timestamp: new Date(parseInt(timestamp, 10) * 1000), + message, + stats, + files: fileChanges, + } + + // Collect diffs if configured + if (options.commitDataConfig?.includeDiffs && fileChanges.length > 0) { + for (const file of fileChanges) { + if (this.shouldIncludeDiff(file.path, file.additions, file.deletions, options.commitDataConfig)) { + const diff = await this.collectFileDiff(sha, file.path, options.commitDataConfig) + if (diff) { + file.diff = diff + file.diffTruncated = diff.includes('[diff truncated') + this.diffsCollected++ + this.tokenUsage += this.estimateTokens(diff) + + // Log progress + if (this.diffsCollected % 5 === 0) { + this.logger?.info(`Collected ${this.diffsCollected} diffs (${this.tokenUsage} tokens used)`) + } + } + } + } + } + + commits.push(commit) + } + + // Store stats for reporting (if in release mode) + if (totalCommitsProcessed > 0) { + this.filteringStats.totalCommitsFound = totalCommitsProcessed + this.filteringStats.trivialCommitsFiltered = skippedTrivialCommits + this.filteringStats.filesProcessed = totalFilesProcessed + this.filteringStats.trivialFilesSkipped = trivialFilesSkipped + } + + if (skippedTrivialCommits > 0) { + this.logger?.info(`Filtered out ${skippedTrivialCommits} commits with only trivial file changes`) + } + + this.logger?.info(`Analyzed ${commits.length} meaningful commits from ${totalCommitsProcessed} total`) + + if (options.commitDataConfig?.includeDiffs && this.diffsCollected > 0) { + this.logger?.info(`Collected ${this.diffsCollected} diffs using ~${this.tokenUsage.toLocaleString()} tokens`) + } + + return commits + } + + private async getStats(options: CollectOptions): Promise { + // For release comparisons, use the commit range + if (options.releaseComparison) { + const range = options.releaseComparison.commitRange + const shortstat = await $`git -C ${this.repoPath} log ${range} --shortstat --format=""`.text() + const authors = await $`git -C ${this.repoPath} log ${range} --format="%ae" | sort -u | wc -l`.text() + + let filesChanged = 0, + linesAdded = 0, + linesDeleted = 0, + totalCommits = 0 + + for (const line of shortstat.split('\n')) { + if (line.includes('changed')) { + totalCommits++ + const fileMatch = line.match(/(\d+) file/) + const insertMatch = line.match(/(\d+) insertion/) + const deleteMatch = line.match(/(\d+) deletion/) + + if (fileMatch?.[1]) { + filesChanged += parseInt(fileMatch[1], 10) + } + if (insertMatch?.[1]) { + linesAdded += parseInt(insertMatch[1], 10) + } + if (deleteMatch?.[1]) { + linesDeleted += parseInt(deleteMatch[1], 10) + } + } + } + + return { + totalCommits, + totalAuthors: parseInt(authors.trim(), 10), + filesChanged, + linesAdded, + linesDeleted, + } + } + + // Original implementation for time-based analysis + const shortstat = await $`git -C ${this.repoPath} log --since="${options.since}" --shortstat --format=""`.text() + const authors = + await $`git -C ${this.repoPath} log --since="${options.since}" --format="%ae" | sort -u | wc -l`.text() + + let filesChanged = 0, + linesAdded = 0, + linesDeleted = 0, + totalCommits = 0 + + for (const line of shortstat.split('\n')) { + if (line.includes('changed')) { + totalCommits++ + const fileMatch = line.match(/(\d+) file/) + const insertMatch = line.match(/(\d+) insertion/) + const deleteMatch = line.match(/(\d+) deletion/) + + if (fileMatch?.[1]) { + filesChanged += parseInt(fileMatch[1], 10) + } + if (insertMatch?.[1]) { + linesAdded += parseInt(insertMatch[1], 10) + } + if (deleteMatch?.[1]) { + linesDeleted += parseInt(deleteMatch[1], 10) + } + } + } + + return { + totalCommits, + totalAuthors: parseInt(authors.trim(), 10), + filesChanged, + linesAdded, + linesDeleted, + } + } +} diff --git a/apps/cli/src/core/orchestrator.ts b/apps/cli/src/core/orchestrator.ts new file mode 100644 index 00000000000..a72effbcf42 --- /dev/null +++ b/apps/cli/src/core/orchestrator.ts @@ -0,0 +1,810 @@ +/* eslint-disable max-depth */ +/* eslint-disable security/detect-non-literal-regexp */ +/* eslint-disable complexity */ +/* eslint-disable max-lines */ +/* eslint-disable no-console */ +import { join } from 'node:path' +import { + type CollectOptions, + DataCollector, + type PullRequest, + type RepositoryData, +} from '@universe/cli/src/core/data-collector' +import type { AIProvider } from '@universe/cli/src/lib/ai-provider' +import { AnalysisWriter } from '@universe/cli/src/lib/analysis-writer' +import type { CacheProvider } from '@universe/cli/src/lib/cache-provider' +import type { Logger } from '@universe/cli/src/lib/logger' +import { ReleaseScanner } from '@universe/cli/src/lib/release-scanner' + +// ============================================================================ +// Types +// ============================================================================ + +export interface AnalysisConfig { + mode?: string // Predefined mode (team-digest, changelog, release-changelog, etc.) + prompt?: string // Custom prompt (file path or inline text) + promptFile?: string // Explicit prompt file path + variables?: Record // Variable substitution for prompts + releaseOptions?: { + // Release-specific options + platform: 'mobile' | 'extension' + version: string + compareWith?: string + } +} + +export interface OutputConfig { + type: string // Output type (slack, markdown, file, etc.) + target?: string // Target destination (file path, channel, etc.) + options?: Record // Type-specific options +} + +export interface OrchestratorConfig { + analysis: AnalysisConfig + outputs: OutputConfig[] + collect: CollectOptions + verbose?: boolean + dryRun?: boolean + saveArtifacts?: boolean + model?: string // AI model to use (defaults to claude-opus-4-1-20250805) + bypassCache?: boolean // Bypass cache for this run +} + +// ============================================================================ +// Prompt Management +// ============================================================================ + +class PromptResolver { + private builtInPromptsPath = join((import.meta.dir as string | undefined) ?? process.cwd(), 'src', 'prompts') + private projectPromptsPath = '.claude/prompts' + + async resolve(promptRef: string): Promise { + // If it's a multiline string or looks like instructions, use as-is + if (promptRef.includes('\n') || promptRef.length > 100) { + return promptRef + } + + // If it ends with .md, treat as file path + if (promptRef.endsWith('.md')) { + return await this.loadFromFile(promptRef) + } + + // Check for built-in prompts + const builtInPath = join(this.builtInPromptsPath, `${promptRef}.md`) + if (await this.fileExists(builtInPath)) { + return await this.loadFromFile(builtInPath) + } + + // Check for project prompts + const projectPath = join(this.projectPromptsPath, `${promptRef}.md`) + if (await this.fileExists(projectPath)) { + return await this.loadFromFile(projectPath) + } + + // Treat as inline prompt if not found as file + return promptRef + } + + private async fileExists(path: string): Promise { + try { + await Bun.file(path).text() + return true + } catch { + return false + } + } + + private async loadFromFile(path: string): Promise { + try { + return await Bun.file(path).text() + } catch (error) { + throw new Error(`Failed to load prompt from ${path}: ${error}`) + } + } + + substituteVariables(prompt: string, variables: Record): string { + let result = prompt + for (const [key, value] of Object.entries(variables)) { + const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + // Use string replace with regex pattern - escapedKey is safe as it's escaped + const dollarPattern = new RegExp(`\\$${escapedKey}`, 'g') + const bracePattern = new RegExp(`{{${escapedKey}}}`, 'g') + result = result.replace(dollarPattern, value) + result = result.replace(bracePattern, value) + } + return result + } +} + +// ============================================================================ +// Analysis Orchestrator +// ============================================================================ + +export interface OrchestratorDependencies { + config: OrchestratorConfig + aiProvider: AIProvider + cacheProvider?: CacheProvider + logger: Logger +} + +export class Orchestrator { + private promptResolver = new PromptResolver() + private dataCollector: DataCollector + private analysisWriter: AnalysisWriter + private startTime: number = 0 + private timings: { dataCollection: number; claudeAnalysis: number } = { dataCollection: 0, claudeAnalysis: 0 } + private config: OrchestratorConfig + private aiProvider: AIProvider + private logger: Logger + + constructor(deps: OrchestratorDependencies) { + this.config = deps.config + this.aiProvider = deps.aiProvider + this.logger = deps.logger + this.dataCollector = new DataCollector( + deps.config.collect.repoPath, + deps.cacheProvider, + deps.config.bypassCache || false, + deps.logger, + ) + this.analysisWriter = new AnalysisWriter() + } + + async execute(): Promise> { + this.startTime = Date.now() + const runId = this.analysisWriter.getRunId() + this.logger.info(`Starting repository analysis${this.config.saveArtifacts ? ` (run: ${runId})` : ''}`) + + // Initialize analysis directory if saving artifacts + if (this.config.saveArtifacts) { + await this.analysisWriter.initialize() + // Save configuration + await this.analysisWriter.saveConfig(this.config) + } + + // Step 1: Collect repository data + const dataStartTime = Date.now() + const data = await this.collectData() + this.timings.dataCollection = Date.now() - dataStartTime + + // Step 2: Run analysis with universal analyzer + const analysisStartTime = Date.now() + const insights = await this.analyze(data) + this.timings.claudeAnalysis = Date.now() - analysisStartTime + + // Step 3: Deliver to outputs + await this.deliver(insights, data) + + // Generate summary + await this.generateSummary(data) + + const totalTime = Date.now() - this.startTime + this.logger.info(`Analysis complete! (${(totalTime / 1000).toFixed(1)}s)`) + this.logger.info(`View artifacts: ${this.analysisWriter.getRunPath()}/`) + + // Return the analysis results for UI consumption + return insights + } + + private async collectData(): Promise { + this.logger.info('Collecting repository data...') + + let data: RepositoryData + + // If in release mode (release-changelog or bug-bisect), augment collect options with release comparison + if ( + (this.config.analysis.mode === 'release-changelog' || this.config.analysis.mode === 'bug-bisect') && + this.config.analysis.releaseOptions + ) { + const scanner = new ReleaseScanner(this.config.collect.repoPath, this.logger) + const { platform, version, compareWith } = this.config.analysis.releaseOptions + + const comparison = await scanner.getReleaseComparison({ + platform, + version, + compareWith, + }) + if (!comparison) { + throw new Error(`Could not find release comparison for ${platform}/${version}`) + } + + this.logger.info(`Analyzing release: ${platform}/${version} (comparing with ${comparison.from.version})`) + + // Add release comparison to collect options + const collectOptions: CollectOptions = { + ...this.config.collect, + releaseComparison: comparison, + } + + data = await this.dataCollector.collect(collectOptions) + } else { + data = await this.dataCollector.collect(this.config.collect) + } + + // Save collected data if saving artifacts + if (this.config.saveArtifacts) { + await this.analysisWriter.saveCommits(data.commits, data.metadata) + await this.analysisWriter.savePullRequests(data.pullRequests) + await this.analysisWriter.saveStats(data.stats) + } + + return data + } + + private async analyze(data: RepositoryData): Promise> { + this.logger.info('Running analysis...') + + // Build the analysis prompt + const prompt = await this.buildPrompt(data) + if (this.config.saveArtifacts) { + await this.analysisWriter.savePrompt(prompt) + } + + // Prepare data context + const context = this.prepareContext(data) + if (this.config.saveArtifacts) { + await this.analysisWriter.saveContext(context) + } + + // Smart injection: replace if template has placeholder, otherwise append + let analysisPrompt: string + if (prompt.includes('{{COMMIT_DATA}}')) { + // New style: Replace the variable + analysisPrompt = prompt.replace(/{{COMMIT_DATA}}/g, context) + } else { + // Legacy style: Append to end + analysisPrompt = `${prompt}\n\n## Repository Data\n\n${context}` + } + + // Estimate tokens (rough approximation: 1 token ≈ 4 characters) + let estimatedTokens = Math.round(analysisPrompt.length / 4) + this.logger.info(`Prepared Claude context (estimated ~${estimatedTokens.toLocaleString()} tokens)...`) + + // Check if we're over Claude's limit (roughly 200k tokens for Claude 3) + const MAX_TOKENS = 150000 // Conservative limit to leave room for response + + if (estimatedTokens > MAX_TOKENS) { + this.logger.warn(`Context too large (${estimatedTokens} tokens), reducing data...`) + + // Step 1: Try again without diffs (prefer PR bodies over diffs) + const reducedContext = this.prepareContext(data, true) // skipDiffs flag + if (prompt.includes('{{COMMIT_DATA}}')) { + analysisPrompt = prompt.replace(/{{COMMIT_DATA}}/g, reducedContext) + } else { + analysisPrompt = `${prompt}\n\n## Repository Data\n\n${reducedContext}` + } + estimatedTokens = Math.round(analysisPrompt.length / 4) + this.logger.info(`Reduced context to ~${estimatedTokens.toLocaleString()} tokens (removed diffs)`) + + // Step 2: If still too large, truncate PR bodies proportionally + if (estimatedTokens > MAX_TOKENS) { + this.logger.warn('Still too large, truncating PR bodies...') + const dataWithTruncatedPRs = this.truncatePRBodies(data, 0.5) // Reduce to 50% of original length + const contextWithTruncatedPRs = this.prepareContext(dataWithTruncatedPRs, true) + if (prompt.includes('{{COMMIT_DATA}}')) { + analysisPrompt = prompt.replace(/{{COMMIT_DATA}}/g, contextWithTruncatedPRs) + } else { + analysisPrompt = `${prompt}\n\n## Repository Data\n\n${contextWithTruncatedPRs}` + } + estimatedTokens = Math.round(analysisPrompt.length / 4) + this.logger.info(`Reduced context to ~${estimatedTokens.toLocaleString()} tokens (truncated PR bodies to 50%)`) + + // Step 3: If still too large, truncate commits + if (estimatedTokens > MAX_TOKENS) { + this.logger.warn('Still too large, truncating commit list...') + const truncatedData = { ...dataWithTruncatedPRs, commits: dataWithTruncatedPRs.commits.slice(0, 100) } + const minimalContext = this.prepareContext(truncatedData, true) + if (prompt.includes('{{COMMIT_DATA}}')) { + analysisPrompt = prompt.replace(/{{COMMIT_DATA}}/g, minimalContext) + } else { + analysisPrompt = `${prompt}\n\n## Repository Data\n\n${minimalContext}` + } + estimatedTokens = Math.round(analysisPrompt.length / 4) + this.logger.info(`Final context: ~${estimatedTokens.toLocaleString()} tokens (kept first 100 commits)`) + } + } + } + + if (this.config.saveArtifacts) { + await this.analysisWriter.saveClaudeInput(analysisPrompt) + } + + if (this.config.verbose) { + this.logger.debug(`Analysis prompt: ${analysisPrompt.slice(0, 500)}...`) + } + + // For now, directly use Claude API since Task is not available in this context + // In production, this would use the Task API + const result = await this.analyzeWithClaude(analysisPrompt) + + // Save Claude's output + if (this.config.saveArtifacts) { + await this.analysisWriter.saveClaudeOutput(result) + } + + return result + } + + private async buildPrompt(data: RepositoryData): Promise { + let promptText = '' + + // Load base prompt + if (this.config.analysis.mode) { + promptText = await this.promptResolver.resolve(this.config.analysis.mode) + } else if (this.config.analysis.promptFile) { + promptText = await this.promptResolver.resolve(this.config.analysis.promptFile) + } else if (this.config.analysis.prompt) { + promptText = await this.promptResolver.resolve(this.config.analysis.prompt) + } else { + // Default to team-digest + promptText = await this.promptResolver.resolve('team-digest') + } + + // Build variables for substitution + const variables: Record = { + ...this.config.analysis.variables, + } + + // Add release context variables for bug-bisect mode + if ( + this.config.analysis.mode === 'bug-bisect' && + this.config.analysis.releaseOptions && + data.metadata.releaseInfo + ) { + variables.PLATFORM = data.metadata.releaseInfo.platform + variables.RELEASE_TO = data.metadata.releaseInfo.to + variables.RELEASE_FROM = data.metadata.releaseInfo.from + } + + // Substitute variables if provided + if (Object.keys(variables).length > 0) { + promptText = this.promptResolver.substituteVariables(promptText, variables) + } + + return promptText + } + + /** + * Truncate PR bodies proportionally to reduce context size + * @param data Original repository data + * @param ratio Ratio to keep (0.5 = keep 50% of each PR body) + */ + private truncatePRBodies(data: RepositoryData, ratio: number): RepositoryData { + const truncatedPRs = data.pullRequests.map((pr: PullRequest) => { + if (pr.body && pr.body.length > 0) { + const targetLength = Math.max(100, Math.floor(pr.body.length * ratio)) // Keep at least 100 chars + const truncatedBody = pr.body.slice(0, targetLength) + (pr.body.length > targetLength ? '... [truncated]' : '') + return { + number: pr.number, + title: pr.title, + body: truncatedBody, + author: pr.author, + state: pr.state, + mergedAt: pr.mergedAt, + mergeCommitSha: pr.mergeCommitSha, + } satisfies PullRequest + } + return { + number: pr.number, + title: pr.title, + body: pr.body, + author: pr.author, + state: pr.state, + mergedAt: pr.mergedAt, + mergeCommitSha: pr.mergeCommitSha, + } satisfies PullRequest + }) as PullRequest[] + + return { + ...data, + pullRequests: truncatedPRs, + } + } + + private prepareContext(data: RepositoryData, skipDiffs: boolean = false): string { + const lines: string[] = [] + let prBodyTokens = 0 + + // Metadata section (compact format) + lines.push('=== REPOSITORY METADATA ===') + lines.push(`Repository: ${data.metadata.repository}`) + lines.push(`Period: ${data.metadata.period}`) + + if (data.metadata.releaseInfo) { + lines.push( + `Release: ${data.metadata.releaseInfo.platform} ${data.metadata.releaseInfo.from} → ${data.metadata.releaseInfo.to}`, + ) + } + + lines.push(`Total commits: ${data.metadata.commitCount}`) + lines.push(`Pull requests: ${data.metadata.prCount}`) + + if (data.metadata.filtering) { + lines.push(`Filtered: ${data.metadata.filtering.trivialCommitsFiltered} trivial commits removed`) + } + lines.push('') + + // Stats section (compact) + lines.push('=== STATISTICS ===') + lines.push(`Authors: ${data.stats.totalAuthors}`) + lines.push(`Files changed: ${data.stats.filesChanged}`) + lines.push(`Lines: +${data.stats.linesAdded} -${data.stats.linesDeleted}`) + lines.push('') + + // Pull requests section with full bodies (up to prBodyLimit) + if (data.pullRequests.length > 0) { + lines.push('=== PULL REQUESTS ===') + for (const pr of data.pullRequests) { + lines.push(`PR #${pr.number}: ${pr.title} [${pr.state}] @${pr.author}`) + if (pr.body) { + // Include full PR body (already truncated to prBodyLimit during collection) + // Preserve markdown formatting with proper newlines + const bodyLines = pr.body.split('\n') + for (const line of bodyLines) { + lines.push(` ${line}`) + } + // Track token usage for PR bodies (rough approximation: 1 token ≈ 4 characters) + prBodyTokens += Math.ceil(pr.body.length / 4) + } + } + lines.push('') + } + + // Log PR body token contribution if significant + if (prBodyTokens > 0) { + this.logger.info(`PR bodies contribute ~${prBodyTokens.toLocaleString()} tokens to context`) + } + + // Commits section (ultra-compact format) + lines.push('=== COMMITS ===') + for (const commit of data.commits) { + // Format: sha | author | message | stats + const date = new Date(commit.timestamp).toISOString().split('T')[0] + lines.push(`${commit.sha.slice(0, 7)} | ${date} | ${commit.author.email.split('@')[0]} | ${commit.message}`) + + // If we have file information, show it compactly + if (commit.files && commit.files.length > 0) { + // Group files by directory for even more compact display + const fileGroups = new Map() + + for (const file of commit.files) { + const parts = file.path.split('/') + const dir = parts.length > 1 ? parts.slice(0, -1).join('/') : '.' + const filename = parts[parts.length - 1] + + if (!fileGroups.has(dir)) { + fileGroups.set(dir, []) + } + + // Status indicators: M=modified, A=added, D=deleted, R=renamed + const statusChar = file.status[0]?.toUpperCase() ?? 'M' + const changes = `${statusChar}:+${file.additions}-${file.deletions}` + const files = fileGroups.get(dir) + if (files) { + files.push(`${filename}(${changes})`) + } + } + + // Output grouped files + for (const [dir, files] of fileGroups) { + if (files.length <= 3) { + lines.push(` ${dir}/: ${files.join(' ')}`) + } else { + // If many files in same dir, summarize + lines.push(` ${dir}/: ${files.slice(0, 2).join(' ')} +${files.length - 2} more`) + } + } + + // Include diffs if available (already in compact diff format) + if (!skipDiffs) { + for (const file of commit.files) { + if (file.diff) { + lines.push(` --- ${file.path} ---`) + // Add indent to diff lines for readability + const diffLines = file.diff.split('\n').map((line: string) => ` ${line}`) + lines.push(...diffLines.slice(0, 10)) // Limit diff preview + if (diffLines.length > 10) { + lines.push(` ... (${diffLines.length - 10} more lines)`) + } + } + } + } + } + } + + return lines.join('\n') + } + + private async analyzeWithClaude(prompt: string): Promise> { + this.logger.info('Analyzing with Claude...') + + const model = this.config.model || 'claude-sonnet-4-5-20250929' + const stream = this.aiProvider.streamText({ + prompt, + model, + maxTokens: 64000, + temperature: 1, + }) + + // Stream and accumulate full response, emitting excerpts for UI + let fullText = '' + + // Buffers for accumulating deltas into meaningful chunks + let textBuffer = '' + let reasoningBuffer = '' + const MIN_EXCERPT_LENGTH = 150 // Minimum chars before emitting (higher threshold to reduce frequency) + const MAX_EXCERPT_LENGTH = 160 // Maximum chars per excerpt (very compact) + const MAX_BUFFER_LENGTH = 220 // Force emit if buffer gets too large + let lastEmittedTime = 0 + const MIN_EMIT_INTERVAL_MS = 2000 // Only emit excerpts every 2 seconds max + + for await (const chunk of stream) { + // Accumulate text + if (chunk.text) { + fullText += chunk.text + textBuffer += chunk.text + + // Check if we should emit a text excerpt + if (this.logger.emitStreamingExcerpt) { + const now = Date.now() + // Throttle excerpt emissions + if (now - lastEmittedTime >= MIN_EMIT_INTERVAL_MS) { + // Find sentence boundaries (including newlines for changelog-style content) + const lastSentenceEnd = Math.max( + textBuffer.lastIndexOf('.\n'), + textBuffer.lastIndexOf('.\n\n'), + textBuffer.lastIndexOf('. '), + textBuffer.lastIndexOf('!\n'), + textBuffer.lastIndexOf('?\n'), + textBuffer.lastIndexOf('\n\n'), // Double newline (paragraph break) + ) + + // Only emit if we have a good boundary and enough content + if ( + (lastSentenceEnd >= MIN_EXCERPT_LENGTH && lastSentenceEnd <= MAX_EXCERPT_LENGTH) || + (textBuffer.length >= MAX_BUFFER_LENGTH && lastSentenceEnd > MIN_EXCERPT_LENGTH) + ) { + const excerpt = textBuffer.slice(0, lastSentenceEnd > 0 ? lastSentenceEnd + 1 : MAX_EXCERPT_LENGTH).trim() + // Filter out markdown headers and very short content + if (excerpt.length >= MIN_EXCERPT_LENGTH && !excerpt.startsWith('##')) { + this.logger.emitStreamingExcerpt(excerpt, false) + textBuffer = textBuffer.slice(lastSentenceEnd > 0 ? lastSentenceEnd + 1 : MAX_EXCERPT_LENGTH) + lastEmittedTime = now + } + } + } + } else { + // CLI mode: write directly to console + process.stdout.write(chunk.text) + } + } + + // Accumulate reasoning + if (chunk.reasoning) { + reasoningBuffer += chunk.reasoning + + // Check if we should emit a reasoning excerpt (prefer reasoning over text) + if (this.logger.emitStreamingExcerpt) { + const now = Date.now() + // Throttle excerpt emissions + if (now - lastEmittedTime >= MIN_EMIT_INTERVAL_MS) { + // Find sentence boundaries for reasoning + const lastSentenceEnd = Math.max( + reasoningBuffer.lastIndexOf('.\n'), + reasoningBuffer.lastIndexOf('.\n\n'), + reasoningBuffer.lastIndexOf('. '), + reasoningBuffer.lastIndexOf('!\n'), + reasoningBuffer.lastIndexOf('?\n'), + reasoningBuffer.lastIndexOf('\n\n'), + ) + + // Only emit if we have a good boundary and enough content + if ( + (lastSentenceEnd >= MIN_EXCERPT_LENGTH && lastSentenceEnd <= MAX_EXCERPT_LENGTH) || + (reasoningBuffer.length >= MAX_BUFFER_LENGTH && lastSentenceEnd > MIN_EXCERPT_LENGTH) + ) { + const excerpt = reasoningBuffer + .slice(0, lastSentenceEnd > 0 ? lastSentenceEnd + 1 : MAX_EXCERPT_LENGTH) + .trim() + // Filter out markdown headers and very short content + if (excerpt.length >= MIN_EXCERPT_LENGTH && !excerpt.startsWith('##')) { + this.logger.emitStreamingExcerpt(excerpt, true) + reasoningBuffer = reasoningBuffer.slice(lastSentenceEnd > 0 ? lastSentenceEnd + 1 : MAX_EXCERPT_LENGTH) + lastEmittedTime = now + } + } + } + } + } + + if (chunk.isComplete) { + // Emit any remaining buffered content + if (this.logger.emitStreamingExcerpt) { + if (textBuffer.trim().length > 0) { + this.logger.emitStreamingExcerpt(textBuffer.trim(), false) + } + if (reasoningBuffer.trim().length > 0) { + this.logger.emitStreamingExcerpt(reasoningBuffer.trim(), true) + } + } else { + } + break + } + } + + // Try to parse as JSON if possible (especially for bug-bisect mode) + if (this.config.analysis.mode === 'bug-bisect') { + try { + // Extract JSON from markdown code blocks if present + const jsonMatch = fullText.match(/```(?:json)?\s*(\{[\s\S]*\})\s*```/) || fullText.match(/(\{[\s\S]*\})/) + const jsonText = jsonMatch && jsonMatch[1] ? jsonMatch[1] : fullText + const parsed = JSON.parse(jsonText) as Record + // Ensure it has the expected structure + if (parsed.suspiciousCommits && Array.isArray(parsed.suspiciousCommits)) { + return parsed + } + // If structure is wrong, wrap it + return { analysis: fullText, parsed } + } catch (error) { + this.logger.warn(`Failed to parse JSON response: ${error}`) + // Return as analysis text but try to extract any JSON-like content + return { analysis: fullText, error: 'Failed to parse JSON response' } + } + } + + // For other modes, try to parse as JSON but fallback to text + try { + return JSON.parse(fullText) as Record + } catch { + return { analysis: fullText } + } + } + + private async deliver(insights: Record, data: RepositoryData): Promise { + this.logger.info('Delivering results...') + + for (const output of this.config.outputs) { + await this.deliverToOutput({ insights, data, output }) + } + } + + private async deliverToOutput(args: { + insights: Record + data: RepositoryData + output: OutputConfig + }): Promise { + const { insights, data, output } = args + this.logger.info(`Delivering to ${output.type}...`) + + switch (output.type) { + case 'stdout': { + console.log('\n=== Analysis Results ===\n') + console.log(JSON.stringify(insights, null, 2)) + break + } + + case 'file': + case 'markdown': { + const path = output.target || 'analysis-output.md' + if (this.config.dryRun) { + this.logger.info(`[DRY RUN] Would save to file: ${path}`) + this.logger.info('[DRY RUN] Preview of content:') + console.log(this.formatAsMarkdown(insights, data).slice(0, 500) + '...') + } else { + // In production, this would use markdown-formatter subagent + await this.saveToFile({ insights, data, path }) + this.logger.info(`Saved to ${path}`) + } + break + } + + case 'slack': { + if (this.config.dryRun) { + this.logger.info(`[DRY RUN] Would publish to Slack channel: ${output.target}`) + this.logger.info(`[DRY RUN] Message preview: ${JSON.stringify(insights).slice(0, 200)}...`) + } else { + // In production, this would use slack-publisher subagent + this.logger.info(`Would publish to Slack channel: ${output.target}`) + this.logger.info('Note: Slack integration requires subagent implementation') + } + break + } + + default: + this.logger.warn(`Unknown output type: ${output.type}`) + } + } + + private async saveToFile(args: { + insights: Record + data: RepositoryData + path: string + }): Promise { + const { insights, data, path } = args + const content = this.formatAsMarkdown(insights, data) + await Bun.write(path, content) + + // Also save to analysis folder + await this.analysisWriter.saveReport(content) + } + + private formatAsMarkdown(insights: Record, data: RepositoryData): string { + const lines: string[] = [] + + lines.push(`# Repository Analysis: ${data.metadata.repository}`) + lines.push(`*Period: ${data.metadata.period}*`) + lines.push(`*Generated: ${data.metadata.collectedAt}*`) + lines.push('') + + if (insights.themes && Array.isArray(insights.themes)) { + lines.push('## Themes') + for (const theme of insights.themes) { + if (theme && typeof theme === 'object' && 'title' in theme && 'description' in theme) { + lines.push(`### ${String(theme.title)}`) + lines.push(String(theme.description)) + lines.push('') + } + } + } + + if (insights.highlights && Array.isArray(insights.highlights)) { + lines.push('## Highlights') + for (const highlight of insights.highlights) { + lines.push(`- ${String(highlight)}`) + } + lines.push('') + } + + if (insights.metrics && typeof insights.metrics === 'object') { + const metrics = insights.metrics as Record + lines.push('## Metrics') + if (typeof metrics.total_commits === 'number') { + lines.push(`- Commits: ${metrics.total_commits}`) + } + if (typeof metrics.total_prs === 'number') { + lines.push(`- Pull Requests: ${metrics.total_prs}`) + } + if (typeof metrics.active_contributors === 'number') { + lines.push(`- Contributors: ${metrics.active_contributors}`) + } + lines.push('') + } + + if (typeof insights === 'string') { + lines.push('## Analysis') + lines.push(insights) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } else if (insights && typeof insights === 'object' && 'analysis' in insights && insights.analysis) { + lines.push('## Analysis') + lines.push(String(insights.analysis)) + } + + return lines.join('\n') + } + + private async generateSummary(data: RepositoryData): Promise { + if (!this.config.saveArtifacts) { + return + } + + // Get stats from data collector if available + const totalCommits = data.metadata.filtering?.totalCommitsFound ?? data.metadata.commitCount + const trivialFiltered = data.metadata.filtering?.trivialCommitsFiltered ?? 0 + + await this.analysisWriter.saveSummary({ + config: this.config, + dataCollection: { + totalCommits, + trivialCommitsFiltered: trivialFiltered, + commitsAnalyzed: data.commits.length, + prsExtracted: data.pullRequests.length, + tokensEstimated: Math.round((data.commits.length * 100 + data.pullRequests.length * 50) / 4), + }, + timing: { + dataCollection: this.timings.dataCollection, + claudeAnalysis: this.timings.claudeAnalysis, + total: Date.now() - this.startTime, + }, + }) + } +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts new file mode 100644 index 00000000000..d6a57aae330 --- /dev/null +++ b/apps/cli/src/index.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// diff --git a/apps/cli/src/lib/ai-provider-vercel.ts b/apps/cli/src/lib/ai-provider-vercel.ts new file mode 100644 index 00000000000..d4aeb3d148a --- /dev/null +++ b/apps/cli/src/lib/ai-provider-vercel.ts @@ -0,0 +1,143 @@ +import { AnthropicProviderOptions, anthropic } from '@ai-sdk/anthropic' +import type { AIProvider, GenerateTextInput, StreamChunk, StreamTextInput } from '@universe/cli/src/lib/ai-provider' +import { generateText, streamText } from 'ai' + +/** + * Vercel AI SDK implementation of AIProvider + * + * Maps Vercel AI SDK responses to our contract interface. + */ +export class VercelAIProvider implements AIProvider { + constructor(private apiKey?: string) { + // API key can be provided via constructor or ANTHROPIC_API_KEY env var + // If provided, set it as environment variable for Vercel AI SDK to use + if (apiKey) { + process.env.ANTHROPIC_API_KEY = apiKey + } + } + + private processFullStreamChunk( + chunk: unknown, + accumulators: { fullText: { value: string }; fullReasoning: { value: string } }, + ): StreamChunk | null { + const chunkObj = typeof chunk === 'object' && chunk !== null && 'type' in chunk ? chunk : null + + if (!chunkObj) { + // Fallback: treat as text chunk + const textChunk = String(chunk) + accumulators.fullText.value += textChunk + return { + text: textChunk, + isComplete: false, + } + } + + const chunkType = chunkObj.type as string + + // Reasoning delta chunks (per Vercel AI SDK docs) + if (chunkType === 'reasoning-delta') { + const reasoningContent = String((chunkObj as { text?: string }).text || '') + if (reasoningContent) { + accumulators.fullReasoning.value += reasoningContent + return { + text: '', + reasoning: reasoningContent, + isComplete: false, + } + } + return null + } + + // Text delta chunks (per Vercel AI SDK docs) + if (chunkType === 'text-delta') { + const textContent = String((chunkObj as { text?: string }).text || '') + if (textContent) { + accumulators.fullText.value += textContent + return { + text: textContent, + reasoning: undefined, + isComplete: false, + } + } + } + + return null + } + + async *streamText(input: StreamTextInput): AsyncGenerator { + const model = anthropic(input.model) + + const result = streamText({ + model, + prompt: input.prompt, + system: input.systemPrompt, + temperature: input.temperature, + ...(input.maxTokens && { maxTokens: input.maxTokens }), + providerOptions: { + anthropic: { + thinking: { type: 'enabled', budgetTokens: 63999 }, + sendReasoning: true, + } satisfies AnthropicProviderOptions, + }, + }) + + const accumulators = { + fullText: { value: '' }, + fullReasoning: { value: '' }, + } + + // Check if fullStream is available (contains reasoning chunks when sendReasoning is true) + // Process fullStream if available to access reasoning, otherwise use textStream + const textStream = result.textStream + const fullStream = 'fullStream' in result ? (result as { fullStream?: AsyncIterable }).fullStream : null + + if (fullStream) { + for await (const chunk of fullStream) { + const processedChunk = this.processFullStreamChunk(chunk, accumulators) + if (processedChunk) { + yield processedChunk + } + } + } else { + // Fallback: process text stream normally + for await (const chunk of textStream) { + const textChunk = String(chunk) + accumulators.fullText.value += textChunk + yield { + text: textChunk, + isComplete: false, + } + } + } + + // Yield final complete chunk (signal only, no duplicate content) + // The orchestrator already accumulated all delta chunks during streaming, + // so we only need to signal completion without re-sending the entire text + yield { + text: '', + reasoning: undefined, + isComplete: true, + } + } + + async generateText(input: GenerateTextInput): Promise { + const model = anthropic(input.model) + + const result = await generateText({ + model, + prompt: input.prompt, + system: input.systemPrompt, + temperature: input.temperature, + ...(input.maxTokens && { maxTokens: input.maxTokens }), + }) + + return result.text + } +} + +/** + * Factory function to create a VercelAIProvider instance + */ +export function createVercelAIProvider(apiKey?: string): VercelAIProvider { + return new VercelAIProvider(apiKey || process.env.ANTHROPIC_API_KEY) +} diff --git a/apps/cli/src/lib/ai-provider.ts b/apps/cli/src/lib/ai-provider.ts new file mode 100644 index 00000000000..24c37fbd150 --- /dev/null +++ b/apps/cli/src/lib/ai-provider.ts @@ -0,0 +1,50 @@ +/** + * AI Provider Contract + * + * Defines the interface for AI text generation providers. + * Allows swapping implementations (e.g., Claude SDK, Vercel AI SDK, etc.) + */ + +export interface StreamTextInput { + prompt: string + systemPrompt?: string + model: string + temperature?: number + maxTokens?: number +} + +export interface StreamChunk { + text: string + isComplete: boolean + reasoning?: string +} + +export interface GenerateTextInput { + prompt: string + systemPrompt?: string + model: string + temperature?: number + maxTokens?: number +} + +/** + * AI Provider interface contract + * + * Provides methods for streaming and non-streaming text generation. + * Implementations should map to their respective SDKs while maintaining this interface. + */ +export interface AIProvider { + /** + * Stream text generation with incremental chunks + * @param input - Configuration for text generation + * @returns Async generator yielding text chunks + */ + streamText(input: StreamTextInput): AsyncGenerator + + /** + * Generate complete text without streaming + * @param input - Configuration for text generation + * @returns Complete generated text + */ + generateText(input: GenerateTextInput): Promise +} diff --git a/apps/cli/src/lib/analysis-writer.ts b/apps/cli/src/lib/analysis-writer.ts new file mode 100644 index 00000000000..e62a3606af9 --- /dev/null +++ b/apps/cli/src/lib/analysis-writer.ts @@ -0,0 +1,241 @@ +import { mkdir } from 'node:fs/promises' +import { join } from 'node:path' + +/** + * Utility for writing analysis artifacts to disk for debugging and audit + */ +export class AnalysisWriter { + private runId: string + private basePath: string + private runPath: string + + constructor(basePath: string = '.analysis') { + this.basePath = basePath + this.runId = this.generateRunId() + this.runPath = join(this.basePath, this.runId) + } + + /** + * Generate a unique run ID based on timestamp + */ + private generateRunId(): string { + const now = new Date() + const year = now.getFullYear() + const month = String(now.getMonth() + 1).padStart(2, '0') + const day = String(now.getDate()).padStart(2, '0') + const hours = String(now.getHours()).padStart(2, '0') + const minutes = String(now.getMinutes()).padStart(2, '0') + const seconds = String(now.getSeconds()).padStart(2, '0') + + return `analysis-${year}${month}${day}-${hours}${minutes}${seconds}` + } + + /** + * Get the run ID for this analysis + */ + getRunId(): string { + return this.runId + } + + /** + * Get the full path to the run directory + */ + getRunPath(): string { + return this.runPath + } + + /** + * Initialize the run directory + */ + async initialize(): Promise { + await mkdir(this.runPath, { recursive: true }) + } + + /** + * Save JSON data to a file + */ + async saveJson(filename: string, data: unknown): Promise { + const filepath = join(this.runPath, filename) + await Bun.write(filepath, JSON.stringify(data, null, 2)) + } + + /** + * Save text content to a file + */ + async saveText(filename: string, content: string): Promise { + const filepath = join(this.runPath, filename) + await Bun.write(filepath, content) + } + + /** + * Save the configuration used for this run + */ + async saveConfig(config: unknown): Promise { + await this.saveJson('config.json', config) + } + + /** + * Save commit data + */ + async saveCommits(commits: unknown[], metadata?: unknown): Promise { + await this.saveJson('commits.json', { + count: commits.length, + metadata, + commits, + }) + } + + /** + * Save pull request data + */ + async savePullRequests(prs: unknown[]): Promise { + await this.saveJson('pull-requests.json', { + count: prs.length, + pullRequests: prs, + }) + } + + /** + * Save repository statistics + */ + async saveStats(stats: unknown): Promise { + await this.saveJson('stats.json', stats) + } + + /** + * Save the context sent to Claude + */ + async saveContext(context: string): Promise { + await this.saveText('context.json', context) + } + + /** + * Save the prompt used + */ + async savePrompt(prompt: string): Promise { + await this.saveText('prompt.md', prompt) + } + + /** + * Save the complete input to Claude + */ + async saveClaudeInput(input: string): Promise { + await this.saveText('claude-input.md', input) + } + + /** + * Save Claude's response + */ + async saveClaudeOutput(output: unknown): Promise { + if (typeof output === 'string') { + await this.saveText('claude-output.md', output) + } else { + await this.saveJson('claude-output.json', output) + } + } + + /** + * Save the final report + */ + async saveReport(report: string): Promise { + await this.saveText('report.md', report) + } + + /** + * Save a debug summary + */ + async saveSummary(data: { + config: unknown + dataCollection: { + totalCommits: number + trivialCommitsFiltered: number + commitsAnalyzed: number + prsExtracted: number + tokensEstimated?: number + } + filesFiltered?: { + snapshots: number + lockfiles: number + generated: number + other: number + } + timing?: { + dataCollection: number + claudeAnalysis: number + total: number + } + }): Promise { + const { config, dataCollection, filesFiltered, timing } = data + + // Type assertion for config structure + const typedConfig = config as { + analysis?: { + mode?: string + releaseOptions?: { + platform?: string + version?: string + compareWith?: string + } + } + collect?: { + since?: string + } + } + + const summary = `# Analysis Run: ${this.runId} + +## Configuration +- Mode: ${typedConfig.analysis?.mode || 'unknown'} +${ + typedConfig.analysis?.releaseOptions + ? `- Platform: ${typedConfig.analysis.releaseOptions.platform} +- Version: ${typedConfig.analysis.releaseOptions.version} +- Comparing with: ${typedConfig.analysis.releaseOptions.compareWith || 'previous'}` + : '' +} +- Since: ${typedConfig.collect?.since || 'unknown'} + +## Data Collection +- Total commits found: ${dataCollection.totalCommits} +- Trivial commits filtered: ${dataCollection.trivialCommitsFiltered} +- Commits analyzed: ${dataCollection.commitsAnalyzed} +- PRs extracted: ${dataCollection.prsExtracted} +${dataCollection.tokensEstimated ? `- Tokens estimated: ~${dataCollection.tokensEstimated.toLocaleString()}` : ''} + +${ + filesFiltered + ? `## Files Filtered +- Snapshots: ${filesFiltered.snapshots} files +- Lockfiles: ${filesFiltered.lockfiles} files +- Generated: ${filesFiltered.generated} files +- Other: ${filesFiltered.other} files` + : '' +} + +${ + timing + ? `## Timing +- Data collection: ${(timing.dataCollection / 1000).toFixed(1)}s +- Claude analysis: ${(timing.claudeAnalysis / 1000).toFixed(1)}s +- Total: ${(timing.total / 1000).toFixed(1)}s` + : '' +} + +## Output Location +- Run ID: ${this.runId} +- Path: ${this.runPath}/ +` + + await this.saveText('summary.md', summary) + } + + /** + * Save list of filtered files + */ + async saveFilteredFiles(files: { path: string; reason: string }[]): Promise { + await this.saveJson('filtered-files.json', { + count: files.length, + files, + }) + } +} diff --git a/apps/cli/src/lib/cache-keys.ts b/apps/cli/src/lib/cache-keys.ts new file mode 100644 index 00000000000..dffacb069c7 --- /dev/null +++ b/apps/cli/src/lib/cache-keys.ts @@ -0,0 +1,106 @@ +import { createHash } from 'node:crypto' +import { type CollectOptions } from '@universe/cli/src/core/data-collector' + +/** + * Generate deterministic cache keys from CollectOptions + * Excludes non-deterministic fields like commitDataConfig that affect output format + */ + +/** + * Create a hash from a string + */ +function hash(input: string): string { + return createHash('sha256').update(input).digest('hex').slice(0, 16) +} + +/** + * Generate cache key components from CollectOptions (excluding output formatting options) + */ +function getCacheKeyComponents(options: CollectOptions): string { + const parts: string[] = [] + + // Repository identifier + if (options.repository?.owner && options.repository.name) { + parts.push(`repo:${options.repository.owner}/${options.repository.name}`) + } else if (options.repoPath) { + parts.push(`repopath:${options.repoPath}`) + } + + // Time-based query + parts.push(`since:${options.since}`) + + // Branch filter + if (options.branch) { + parts.push(`branch:${options.branch}`) + } + + // Author filter + if (options.author) { + parts.push(`author:${options.author}`) + } + + // Team filters + if (options.teamFilter?.length) { + const sortedEmails = [...options.teamFilter].sort().join(',') + parts.push(`team:${sortedEmails}`) + } + + if (options.teamUsernames?.length) { + const sortedUsernames = [...options.teamUsernames].sort().join(',') + parts.push(`usernames:${sortedUsernames}`) + } + + // Include open PRs flag + if (options.includeOpenPrs) { + parts.push('includeOpenPrs:true') + } + + // Release comparison (deterministic by version range) + if (options.releaseComparison) { + parts.push(`release:${options.releaseComparison.from.version}-${options.releaseComparison.to.version}`) + parts.push(`platform:${options.releaseComparison.to.platform}`) + parts.push(`range:${options.releaseComparison.commitRange}`) + } + + // Exclude trivial commits flag + if (options.excludeTrivialCommits) { + parts.push('excludeTrivial:true') + } + + return parts.join('|') +} + +/** + * Generate cache key for commits + */ +export function getCommitsCacheKey(options: CollectOptions): string { + const components = getCacheKeyComponents(options) + return `commits:${hash(components)}` +} + +/** + * Generate cache key for pull requests + */ +export function getPullRequestsCacheKey(options: CollectOptions): string { + const components = getCacheKeyComponents(options) + return `prs:${hash(components)}` +} + +/** + * Generate cache key for stats + */ +export function getStatsCacheKey(options: CollectOptions): string { + const components = getCacheKeyComponents(options) + return `stats:${hash(components)}` +} + +/** + * Generate pattern to invalidate all cache entries for a repository + */ +export function getRepositoryCachePattern(repository?: { owner?: string; name?: string }): string { + if (repository?.owner && repository.name) { + const repoHash = hash(`repo:${repository.owner}/${repository.name}`) + return `%:${repoHash}%` + } + return '%' +} diff --git a/apps/cli/src/lib/cache-provider-sqlite.ts b/apps/cli/src/lib/cache-provider-sqlite.ts new file mode 100644 index 00000000000..4b7e2119fd1 --- /dev/null +++ b/apps/cli/src/lib/cache-provider-sqlite.ts @@ -0,0 +1,130 @@ +import { Database } from 'bun:sqlite' +import { existsSync } from 'node:fs' +import { mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { type CacheProvider } from '@universe/cli/src/lib/cache-provider' + +/** + * SQLite implementation of CacheProvider using Bun's built-in SQLite + */ +export class SqliteCacheProvider implements CacheProvider { + private db: Database + private readonly dbPath: string + + constructor(dbPath?: string) { + // Default to ~/.gh-agent/cache.db + this.dbPath = dbPath || join(process.env.HOME || process.env.USERPROFILE || '.', '.gh-agent', 'cache.db') + this.ensureCacheDirectorySync() + this.db = new Database(this.dbPath) + this.initializeSchema() + this.cleanupExpired() + } + + private ensureCacheDirectorySync(): void { + const dir = join(this.dbPath, '..') + if (!existsSync(dir)) { + try { + // Use sync version for constructor - Bun will create parent directories + mkdir(dir, { recursive: true }).catch((error) => { + // eslint-disable-next-line no-console + console.error(`[WARN] Failed to create cache directory: ${error}`) + }) + } catch { + // Ignore - will fail gracefully on database creation + } + } + } + + private initializeSchema(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS cache_entries ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + expires_at INTEGER, + created_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_expires_at ON cache_entries(expires_at); + `) + } + + /** + * Remove expired entries (called on initialization and periodically) + */ + private cleanupExpired(): void { + const now = Date.now() + this.db.exec(`DELETE FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at < ${now}`) + } + + async get(key: string): Promise { + try { + const now = Date.now() + const stmt = this.db.prepare(` + SELECT value, expires_at + FROM cache_entries + WHERE key = ? AND (expires_at IS NULL OR expires_at >= ?) + `) + + const result = stmt.get(key, now) as { value: string; expires_at: number | null } | undefined + + if (!result) { + return null + } + + // Clean up expired entries periodically (every 100 reads) + if (Math.random() < 0.01) { + this.cleanupExpired() + } + + return JSON.parse(result.value) as T + } catch (_error) { + return null + } + } + + // eslint-disable-next-line max-params -- Required to match CacheProvider interface + async set(key: string, value: T, ttlSeconds?: number): Promise { + try { + const serialized = JSON.stringify(value) + const now = Date.now() + const expiresAt = ttlSeconds ? now + ttlSeconds * 1000 : null + + const stmt = this.db.prepare(` + INSERT OR REPLACE INTO cache_entries (key, value, expires_at, created_at) + VALUES (?, ?, ?, ?) + `) + + stmt.run(key, serialized, expiresAt, now) + } catch (_error) { + // Don't throw - graceful degradation + } + } + + async invalidate(key: string): Promise { + try { + const stmt = this.db.prepare('DELETE FROM cache_entries WHERE key = ?') + stmt.run(key) + } catch (_error) {} + } + + async invalidatePattern(pattern: string): Promise { + try { + // SQLite uses LIKE for pattern matching + const stmt = this.db.prepare('DELETE FROM cache_entries WHERE key LIKE ?') + stmt.run(pattern) + } catch (_error) {} + } + + async clear(): Promise { + try { + this.db.exec('DELETE FROM cache_entries') + } catch (_error) {} + } + + /** + * Close the database connection (call when done) + */ + close(): void { + this.db.close() + } +} diff --git a/apps/cli/src/lib/cache-provider.ts b/apps/cli/src/lib/cache-provider.ts new file mode 100644 index 00000000000..e6126843860 --- /dev/null +++ b/apps/cli/src/lib/cache-provider.ts @@ -0,0 +1,41 @@ +// ============================================================================ +// Cache Provider Interface +// ============================================================================ + +/** + * Contract for cache providers - allows swapping implementations + * (e.g., SQLite, Redis, in-memory) without changing consuming code + */ +export interface CacheProvider { + /** + * Retrieve a value from cache by key + * @param key Cache key + * @returns Cached value or null if not found/expired + */ + get(key: string): Promise + + /** + * Store a value in cache with optional TTL + * @param key Cache key + * @param value Value to cache (will be serialized as JSON) + * @param ttlSeconds Optional time-to-live in seconds (default: no expiration) + */ + set(key: string, value: T, ttlSeconds?: number): Promise + + /** + * Remove a specific key from cache + * @param key Cache key to invalidate + */ + invalidate(key: string): Promise + + /** + * Remove all keys matching a pattern (supports SQL LIKE patterns) + * @param pattern Pattern to match (e.g., "commits:%" or "prs:abc%") + */ + invalidatePattern(pattern: string): Promise + + /** + * Clear all entries from cache + */ + clear(): Promise +} diff --git a/apps/cli/src/lib/logger.ts b/apps/cli/src/lib/logger.ts new file mode 100644 index 00000000000..5608423ca3d --- /dev/null +++ b/apps/cli/src/lib/logger.ts @@ -0,0 +1,221 @@ +/** + * Logger interface for dependency injection + * Allows different logging strategies for interactive vs non-interactive modes + */ +export interface Logger { + info(message: string): void + warn(message: string): void + error(message: string): void + debug(message: string): void + /** + * Emit a streaming excerpt from agent thinking/output (optional, for UI progress updates) + */ + emitStreamingExcerpt?(excerpt: string, isReasoning?: boolean): void +} + +/** + * Progress stage type for UI progress events + */ +export type ProgressStage = 'idle' | 'collecting' | 'analyzing' | 'delivering' | 'complete' | 'error' + +/** + * Progress event type for categorizing messages + */ +export type ProgressEventType = 'reasoning' | 'output' | 'info' + +/** + * Progress event interface for UI updates + */ +export interface ProgressEvent { + stage: ProgressStage + message?: string + progress?: number // 0-100 + cacheInfo?: { + type: 'commits' | 'prs' | 'stats' + count: number + } + /** + * Whether this is reasoning (thinking) content from AI + */ + isReasoning?: boolean + /** + * Type of event for visual distinction in UI + */ + eventType?: ProgressEventType +} + +/** + * ConsoleLogger - Direct console output for non-interactive CLI mode + */ +export class ConsoleLogger implements Logger { + constructor(private readonly verbose: boolean = false) {} + + info(message: string): void { + // eslint-disable-next-line no-console + console.log(`[INFO] ${message}`) + } + + warn(message: string): void { + // eslint-disable-next-line no-console + console.warn(`[WARN] ${message}`) + } + + error(message: string): void { + // eslint-disable-next-line no-console + console.error(`[ERROR] ${message}`) + } + + debug(message: string): void { + if (this.verbose) { + // eslint-disable-next-line no-console + console.log(`[DEBUG] ${message}`) + } + } +} + +/** + * ProgressLogger - Emits progress events for interactive Ink UI mode + * Suppresses stdout to avoid interfering with Ink rendering + */ +export class ProgressLogger implements Logger { + constructor( + private readonly onProgress: (event: ProgressEvent) => void, + private readonly verbose: boolean = false, + ) {} + + info(message: string): void { + // Suppress redundant messages that are already handled by stage transitions + if (this.shouldSuppress(message)) { + return + } + + // Parse message to determine stage and emit appropriate progress event + const stage = this.determineStage(message) + const cleanMessage = message.replace(/^\[INFO\]\s*/, '').trim() + + // Detect cache hit messages and extract cache info + const cacheInfo = this.parseCacheInfo(message) + + if (cleanMessage) { + this.onProgress({ + stage, + message: cleanMessage, + eventType: 'info', + ...(cacheInfo && { cacheInfo }), + }) + } + } + + warn(message: string): void { + const cleanMessage = message.replace(/^\[WARN\]\s*/, '').trim() + // Warnings don't change stage, but emit as progress updates + if (cleanMessage) { + this.onProgress({ stage: 'collecting', message: cleanMessage, eventType: 'info' }) + } + } + + error(message: string): void { + const cleanMessage = message.replace(/^\[ERROR\]\s*/, '').trim() + this.onProgress({ stage: 'error', message: cleanMessage, eventType: 'info' }) + } + + debug(message: string): void { + if (this.verbose) { + const cleanMessage = message.replace(/^\[DEBUG\]\s*/, '').trim() + // Debug messages are emitted as progress updates during current stage + if (cleanMessage) { + this.onProgress({ stage: 'collecting', message: cleanMessage, eventType: 'info' }) + } + } + } + + emitStreamingExcerpt(excerpt: string, isReasoning = false): void { + // Excerpts are already trimmed to meaningful chunks (complete sentences or size limits) + // Just ensure they're not too long for display (safety check) + const maxDisplayLength = 250 + const displayExcerpt = excerpt.length > maxDisplayLength ? `${excerpt.slice(0, maxDisplayLength)}...` : excerpt + + // Emit as progress event during analyzing stage with reasoning flag and event type + this.onProgress({ + stage: 'analyzing', + message: displayExcerpt, + isReasoning, + eventType: isReasoning ? 'reasoning' : 'output', + }) + } + + /** + * Check if a message should be suppressed (not emitted as progress event) + */ + private shouldSuppress(message: string): boolean { + return message.includes('Scanning for') || message.includes('Starting repository analysis') + } + + /** + * Determine the progress stage based on message content + */ + private determineStage(message: string): ProgressEvent['stage'] { + // Stage transitions - check these first + if (message.includes('Collecting repository data')) { + return 'collecting' + } + if (message.includes('Running analysis') || message.includes('Analyzing with Claude')) { + return 'analyzing' + } + if (message.includes('Delivering to') || message.includes('Delivering results')) { + return 'delivering' + } + if (message.includes('Analysis complete')) { + return 'complete' + } + + // Batch progress updates + if ( + (message.includes('Fetched') && message.includes('PRs') && message.includes('batch')) || + message.includes('Successfully fetched') || + (message.includes('Found') && (message.includes('commits') || message.includes('pull requests'))) || + message.includes('Extracting PR information') || + message.includes('Getting commits for release') + ) { + return 'collecting' + } + + // Default to collecting stage for other INFO messages + return 'collecting' + } + + /** + * Parse cache hit information from log messages + */ + private parseCacheInfo(message: string): ProgressEvent['cacheInfo'] | undefined { + // Pattern: "Cache hit: Found X commits in cache" + // Pattern: "Cache hit: Found X PRs in cache" + const cacheHitMatch = message.match(/Cache hit: Found (\d+) (commits|PRs|pull requests) in cache/i) + if (cacheHitMatch) { + const count = parseInt(cacheHitMatch[1] || '0', 10) + const typeStr = cacheHitMatch[2]?.toLowerCase() || '' + + let type: 'commits' | 'prs' | 'stats' + if (typeStr.includes('commit')) { + type = 'commits' + } else if (typeStr.includes('pr') || typeStr.includes('pull request')) { + type = 'prs' + } else { + return undefined + } + + return { type, count } + } + + // Pattern: "Cache hit: Found X stats in cache" (if stats caching exists) + const statsCacheMatch = message.match(/Cache hit: Found (\d+) stats? in cache/i) + if (statsCacheMatch) { + return { + type: 'stats', + count: parseInt(statsCacheMatch[1] || '0', 10), + } + } + + return undefined + } +} diff --git a/apps/cli/src/lib/pr-body-cleaner.ts b/apps/cli/src/lib/pr-body-cleaner.ts new file mode 100644 index 00000000000..d759f98ae0a --- /dev/null +++ b/apps/cli/src/lib/pr-body-cleaner.ts @@ -0,0 +1,496 @@ +/** + * PR Body Cleaner + * + * Intelligently removes unnecessary content from PR bodies while preserving + * important technical details, code blocks, and CURSOR_SUMMARY blocks. + */ + +/** + * Cleans a PR body by removing unnecessary content while preserving valuable information + * @param body The raw PR body text + * @returns Cleaned PR body with unnecessary content removed + */ +export function cleanPRBody(body: string): string { + if (!body || body.trim().length === 0) { + return body + } + + let cleaned = body + + // Step 1: Extract CURSOR_SUMMARY content and remove all HTML comment markers + cleaned = removeHTMLCommentsExceptCursorSummary(cleaned) + + // Step 1.5: Remove Cursor Bugbot footer notes (may appear outside CURSOR_SUMMARY blocks) + cleaned = removeCursorBugbotFooters(cleaned) + + // Step 2: Remove image/video markdown + cleaned = removeImageVideoMarkdown(cleaned) + + // Step 3: Clean tables (remove if only images/videos) + cleaned = cleanTables(cleaned) + + // Step 4: Clean external links (keep text, remove long URLs) + cleaned = cleanExternalLinks(cleaned) + + // Step 5: Remove empty sections + cleaned = removeEmptySections(cleaned) + + // Step 6: Remove minimal value sections + cleaned = removeMinimalValueSections(cleaned) + + // Step 7: Remove redundant sections (screen captures, testing) + cleaned = removeRedundantSections(cleaned) + + // Step 8: Remove redundant headers + cleaned = removeRedundantHeaders(cleaned) + + // Step 9: Aggressive whitespace normalization (max 1 blank line) + cleaned = normalizeWhitespace(cleaned) + + return cleaned +} + +/** + * Extract CURSOR_SUMMARY content and remove all HTML comment markers + */ +function removeHTMLCommentsExceptCursorSummary(text: string): string { + // Extract CURSOR_SUMMARY content (without the comment markers) + const cursorSummaryPlaceholder = '___CURSOR_SUMMARY_PLACEHOLDER___' + const cursorSummaryRegex = /([\s\S]*?)/gi + const summaries: string[] = [] + let matchIndex = 0 + + // Extract just the content between the markers + let textWithProtection = text.replace(cursorSummaryRegex, (match, content) => { + // Clean the content before storing: remove Cursor Bugbot footer notes + let cleanedContent = content.trim() + + // Remove Cursor Bugbot footer notes (metadata lines) + cleanedContent = cleanedContent.replace( + />\s*\[!NOTE\]\s*\n\s*>\s*\[Cursor Bugbot\].*?Configure \[here\].*?<\/sup>/gi, + '', + ) + cleanedContent = cleanedContent.replace( + />\s*Written by \[Cursor Bugbot\].*?Configure \[here\].*?<\/sup>/gi, + '', + ) + cleanedContent = cleanedContent.replace( + />\s*\[Cursor Bugbot\].*?is generating a summary.*?Configure \[here\].*?<\/sup>/gi, + '', + ) + + // Clean up any leftover "> " prefixes from blockquotes + cleanedContent = cleanedContent.replace(/^>\s*/gm, '') + + summaries.push(cleanedContent.trim()) // Store cleaned content + return `${cursorSummaryPlaceholder}${matchIndex++}` + }) + + // Remove all other HTML comments + let previousTextWithProtection: string + do { + previousTextWithProtection = textWithProtection + textWithProtection = textWithProtection.replace(//g, '') + } while (textWithProtection !== previousTextWithProtection) + + // Restore CURSOR_SUMMARY content (without comment markers and footers) + summaries.forEach((summary, index) => { + textWithProtection = textWithProtection.replace(`${cursorSummaryPlaceholder}${index}`, summary) + }) + + return textWithProtection +} + +/** + * Remove Cursor Bugbot footer notes (metadata lines) + */ +function removeCursorBugbotFooters(text: string): string { + let cleaned = text + + // Remove standalone footer notes (outside CURSOR_SUMMARY blocks) + // Pattern 1: > [!NOTE]\n> [Cursor Bugbot]...Configure [here]... + cleaned = cleaned.replace( + />\s*\[!NOTE\]\s*\n\s*>\s*\[Cursor Bugbot\][\s\S]*?Configure \[here\][\s\S]*?<\/sup>\s*/gi, + '', + ) + + // Pattern 2: > Written by [Cursor Bugbot]...Configure [here]... + cleaned = cleaned.replace(/>\s*Written by \[Cursor Bugbot\][\s\S]*?Configure \[here\][\s\S]*?<\/sup>\s*/gi, '') + + // Pattern 3: > [Cursor Bugbot]...is generating a summary...Configure [here]... + cleaned = cleaned.replace( + />\s*\[Cursor Bugbot\][\s\S]*?is generating a summary[\s\S]*?Configure \[here\][\s\S]*?<\/sup>\s*/gi, + '', + ) + + // Pattern 4: Standalone note blocks with Cursor Bugbot (any variant) + cleaned = cleaned.replace(/>\s*\[!NOTE\]\s*\n\s*>\s*\[Cursor Bugbot\][\s\S]*?<\/sup>\s*/gi, '') + + // Pattern 5: Any blockquote line containing Cursor Bugbot footer + cleaned = cleaned.replace(/>\s*\[Cursor Bugbot\][\s\S]*?<\/sup>\s*/gi, '') + + return cleaned +} + +/** + * Remove image and video markdown links + */ +function removeImageVideoMarkdown(text: string): string { + // Remove image markdown: ![alt](url) or ![alt](url "title") + let cleaned = text.replace(/!\[([^\]]*)\]\([^)]+\)/g, '') + + // Remove video markdown patterns like [Screen Recording ...](url) + cleaned = cleaned.replace(/\[Screen Recording[^\]]*\]\([^)]+\)/gi, '') + cleaned = cleaned.replace(/\[Screen Recording[^\]]*\]\([^)]+\)/gi, '') + + // Remove tags + cleaned = cleaned.replace(/]*>/gi, '') + + // Remove video links that look like markdown + cleaned = cleaned.replace(/\[.*\.mov.*\]\([^)]+\)/gi, '') + cleaned = cleaned.replace(/\[.*\.mp4.*\]\([^)]+\)/gi, '') + + return cleaned +} + +/** + * Remove tables that only contain images/videos + */ +function cleanTables(text: string): string { + // Match table blocks - split into parts to avoid unsafe regex + const lines = text.split('\n') + const result: string[] = [] + let inTable = false + let tableLines: string[] = [] + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (!line) { + result.push('') + continue + } + + const isTableRow = line.trim().startsWith('|') && line.trim().endsWith('|') + + if (isTableRow) { + if (!inTable) { + inTable = true + tableLines = [] + } + tableLines.push(line) + } else { + if (inTable) { + // Process accumulated table + const tableMatch = tableLines.join('\n') + if (shouldRemoveTable(tableMatch)) { + // Skip this table + } else { + result.push(...tableLines) + } + inTable = false + tableLines = [] + } + result.push(line) + } + } + + // Handle table at end of text + if (inTable && tableLines.length > 0) { + const tableMatch = tableLines.join('\n') + if (!shouldRemoveTable(tableMatch)) { + result.push(...tableLines) + } + } + + return result.join('\n') +} + +function shouldRemoveTable(tableMatch: string): boolean { + // Remove empty tables (only separators like | --- | --- |) + const cleanedForEmpty = tableMatch.replace(/[\s|:-]/g, '') + if (cleanedForEmpty.length === 0) { + return true + } + + // Check if table only contains image/video links or empty cells + const imageVideoPattern = /!\[|\]\([^)]+\)|Screen Recording|\.mov|\.mp4|\.png|\.jpg|\.jpeg|\.gif|\.webp/gi + const hasOnlyImagesOrVideos = imageVideoPattern.test(tableMatch) + const textPattern = /[a-zA-Z]{3,}/ + const cleanedTable = tableMatch.replace(/!\[|\]\([^)]+\)|Screen Recording/gi, '') + const hasTextContent = textPattern.test(cleanedTable) + + // If table only has images/videos and no substantial text, remove it + return hasOnlyImagesOrVideos && !hasTextContent +} + +/** + * Clean external links - keep text but remove long URLs + */ +function cleanExternalLinks(text: string): string { + // Match markdown links: [text](url) + const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g + return text.replace(linkRegex, (match) => { + const linkMatch = match.match(/\[([^\]]+)\]\(([^)]+)\)/) + if (!linkMatch || !linkMatch[1] || !linkMatch[2]) { + return match + } + + const linkText = linkMatch[1] + const url = linkMatch[2] + + // Keep GitHub links (they're short and useful) + if (url.startsWith('https://github.com/') || url.startsWith('http://github.com/')) { + return match + } + + // Keep relative links + if (url.startsWith('/') || url.startsWith('./') || url.startsWith('../')) { + return match + } + + // Keep short URLs (< 50 chars) + if (url.length <= 50) { + return match + } + + // For long URLs, keep only the text + return linkText + }) +} + +/** + * Remove empty sections (headers with no content or only whitespace) + */ +function removeEmptySections(text: string): string { + // Match sections: ## Header followed by content until next ## or end + const sectionRegex = /(##+\s+[^\n]+)\n([\s\S]*?)(?=\n##+\s+|$)/g + let cleaned = text + + cleaned = cleaned.replace(sectionRegex, (match) => { + const sectionMatch = match.match(/(##+\s+[^\n]+)\n([\s\S]*?)(?=\n##+\s+|$)/) + if (!sectionMatch) { + return match + } + + const content = sectionMatch[2] || '' + // Check if content is empty or only whitespace + const trimmedContent = content.trim() + if (trimmedContent.length === 0) { + return '' // Remove empty section + } + return match // Keep section with content + }) + + return cleaned +} + +/** + * Remove minimal value sections (very short descriptions, redundant changes lists) + */ +function removeMinimalValueSections(text: string): string { + let cleaned = text + + // Remove "## Description" sections that are very short and redundant + const descriptionRegex = /##\s+Description\s*\n([\s\S]*?)(?=\n## |$)/gi + cleaned = cleaned.replace(descriptionRegex, (match) => { + const sectionMatch = match.match(/##\s+Description\s*\n([\s\S]*?)(?=\n## |$)/i) + if (!sectionMatch) { + return match + } + + const content = sectionMatch[1] || '' + const trimmedContent = content.trim() + + // Remove if very short (< 50 chars) and doesn't add value + if (trimmedContent.length < 50) { + return '' + } + + return match + }) + + // Remove "## Changes" sections that are just bullet lists without detail + const changesRegex = /##\s+Changes\s*\n([\s\S]*?)(?=\n## |$)/gi + cleaned = cleaned.replace(changesRegex, (match) => { + const sectionMatch = match.match(/##\s+Changes\s*\n([\s\S]*?)(?=\n## |$)/i) + if (!sectionMatch) { + return match + } + + const content = sectionMatch[1] || '' + const trimmedContent = content.trim() + + // Check if it's just a list of very short bullets + const lines = trimmedContent.split('\n').filter((line) => line.trim().length > 0) + const allShortBullets = lines.every((line) => { + const trimmed = line.trim() + return trimmed.startsWith('-') && trimmed.length < 80 + }) + + // Remove if all bullets are very short (likely redundant with CURSOR_SUMMARY) + if (allShortBullets && lines.length < 5) { + return '' + } + + return match + }) + + // Remove "## Implementation Details" that are redundant with CURSOR_SUMMARY + const implDetailsRegex = /##\s+Implementation Details\s*\n([\s\S]*?)(?=\n## |$)/gi + cleaned = cleaned.replace(implDetailsRegex, (match) => { + const sectionMatch = match.match(/##\s+Implementation Details\s*\n([\s\S]*?)(?=\n## |$)/i) + if (!sectionMatch) { + return match + } + + const content = sectionMatch[1] || '' + const trimmedContent = content.trim() + + // Remove if very short (< 100 chars) - likely redundant + if (trimmedContent.length < 100) { + return '' + } + + return match + }) + + return cleaned +} + +/** + * Remove redundant headers (headers with no meaningful content after them) + */ +function removeRedundantHeaders(text: string): string { + let cleaned = text + + // Match sections and check if header should be removed + const sectionRegex = /(##+\s+[^\n]+)\n([\s\S]*?)(?=\n##+\s+|$)/g + + cleaned = cleaned.replace(sectionRegex, (match) => { + const sectionMatch = match.match(/(##+\s+[^\n]+)\n([\s\S]*?)(?=\n##+\s+|$)/) + if (!sectionMatch) { + return match + } + + const header = sectionMatch[1] || '' + const content = sectionMatch[2] || '' + const trimmedContent = content.trim() + + // If header is followed immediately by CURSOR_SUMMARY-like content and nothing else + // Check if content starts with --- (CURSOR_SUMMARY marker pattern) + if (trimmedContent.startsWith('---') && trimmedContent.length < 200) { + // This might be redundant, but let's be conservative and keep it + return match + } + + // Remove headers that are redundant (e.g., "## Description" when content is minimal) + const headerLower = header.toLowerCase() + if (headerLower.includes('description') && trimmedContent.length < 30) { + // Keep the content, remove the header + return trimmedContent + } + + return match + }) + + return cleaned +} + +/** + * Remove redundant sections + */ +function removeRedundantSections(text: string): string { + let cleaned = text + + // Remove "Screen Captures" or "Screenshots" sections that only contain images + // Match: ## Screen Captures / ## Screenshots followed by content ending before next ## + const screenshotSectionRegex = /##\s+(Screen Captures|Screenshots|Screenshots?)\s*\n([\s\S]*?)(?=\n## |$)/gi + cleaned = cleaned.replace(screenshotSectionRegex, (match) => { + const sectionMatch = match.match(/##\s+(Screen Captures|Screenshots|Screenshots?)\s*\n([\s\S]*?)(?=\n## |$)/i) + if (!sectionMatch) { + return match + } + + const content = sectionMatch[2] || '' + // If content only has images/videos/tables with images, remove it + const mediaPattern = /!\[|\]\([^)]+\)|Screen Recording|\.mov|\.mp4| { + const sectionMatch = match.match(/##\s+(Testing|How Has This Been Tested\?)\s*\n([\s\S]*?)(?=\n## |$)/i) + if (!sectionMatch) { + return match + } + + const content = sectionMatch[2] || '' + const trimmedContent = content.trim() + + // Remove if empty + if (trimmedContent.length === 0) { + return '' + } + + // Remove if only checkboxes with no descriptions + const checkboxOnly = /^[\s-]*\[[ xX]\]/m.test(trimmedContent) && trimmedContent.length < 100 + if (checkboxOnly) { + return '' + } + + // Remove if content is just minimal phrases like "locally", "manually", "on simulator" + const minimalPhrases = + /^(locally|manually|on simulator|tested locally|tested manually|local|ios simulator|android sim)[\s.]*$/i + if (minimalPhrases.test(trimmedContent)) { + return '' + } + + // Remove if content is very short (< 50 chars) and doesn't contain meaningful text + const meaningfulTextPattern = /[a-zA-Z]{10,}/ + if (trimmedContent.length < 50 && !meaningfulTextPattern.test(trimmedContent)) { + return '' + } + + // Keep if it has meaningful content (> 100 chars of actual text) + const textOnly = trimmedContent.replace(/[\s\-[\]xX]/g, '') + if (textOnly.length < 100) { + return '' + } + + return match + }) + + return cleaned +} + +/** + * Normalize whitespace - aggressive cleanup + */ +function normalizeWhitespace(text: string): string { + // Collapse multiple blank lines to max 1 consecutive blank line + let cleaned = text.replace(/\n{3,}/g, '\n\n') + + // Trim trailing whitespace from lines + cleaned = cleaned + .split('\n') + .map((line) => line.trimEnd()) + .join('\n') + + // Remove leading/trailing blank lines + cleaned = cleaned.replace(/^\n+|\n+$/g, '') + + // Remove blank lines immediately after headers + cleaned = cleaned.replace(/(##+\s+[^\n]+)\n\n+/g, '$1\n') + + return cleaned +} diff --git a/apps/cli/src/lib/release-scanner.ts b/apps/cli/src/lib/release-scanner.ts new file mode 100644 index 00000000000..3a268135da2 --- /dev/null +++ b/apps/cli/src/lib/release-scanner.ts @@ -0,0 +1,283 @@ +/* eslint-disable no-console */ + +import type { Logger } from '@universe/cli/src/lib/logger' +import { $ } from 'bun' + +// ============================================================================ +// Types +// ============================================================================ + +export interface Release { + platform: 'mobile' | 'extension' + version: string + branch: string + major: number + minor: number + patch: number + prerelease?: string +} + +export interface ReleaseComparison { + from: Release + to: Release + commitRange: string +} + +// ============================================================================ +// Release Scanner +// ============================================================================ + +export class ReleaseScanner { + constructor( + private repoPath: string = process.cwd(), + private logger?: Logger, + ) {} + + /** + * Scan all release branches for a given platform + */ + async scanReleases(platform?: 'mobile' | 'extension'): Promise { + this.logger?.info(`Scanning for ${platform || 'all'} release branches...`) + + // Get all remote branches + const result = await $`git -C ${this.repoPath} branch -r`.text() + const branches = result + .split('\n') + .map((b: string) => b.trim()) + .filter(Boolean) + + // Filter for release branches + const releasePattern = platform ? `origin/releases/${platform}/` : 'origin/releases/' + + const releaseBranches = branches + .filter((b: string) => b.includes(releasePattern)) + .filter((b: string) => !b.includes('->')) // Exclude symbolic refs + .filter((b: string) => !b.includes('/dev')) // Exclude dev branches + .filter((b: string) => !b.match(/cherry|kickstart|mirror|temp|mp\//)) // Exclude special branches + + // Parse into Release objects + const releases: Release[] = [] + + for (const branch of releaseBranches) { + const release = this.parseReleaseBranch(branch) + if (release) { + releases.push(release) + } + } + + // Sort by version (newest first) + return releases.sort((a, b) => this.compareVersions(b, a)) + } + + /** + * Get the latest release for a platform + */ + async getLatestRelease(platform: 'mobile' | 'extension'): Promise { + const releases = await this.scanReleases(platform) + return releases[0] || null + } + + /** + * Get the previous release before a given version + */ + async getPreviousRelease(release: Release): Promise { + const releases = await this.scanReleases(release.platform) + + // Find the current release index + const currentIndex = releases.findIndex((r) => r.version === release.version && r.platform === release.platform) + + if (currentIndex === -1 || currentIndex === releases.length - 1) { + return null + } + + // Return the next one (which is older since we sorted newest first) + const nextRelease = releases[currentIndex + 1] + return nextRelease ?? null + } + + /** + * Find a specific release by platform and version + */ + async findRelease(platform: 'mobile' | 'extension', version: string): Promise { + const releases = await this.scanReleases(platform) + return releases.find((r) => r.version === version) || null + } + + async getReleaseComparison(args: { + platform: 'mobile' | 'extension' + version: string + compareWith?: string + }): Promise { + const { platform, version, compareWith } = args + const toRelease = await this.findRelease(platform, version) + if (!toRelease) { + throw new Error(`Release ${platform}/${version} not found`) + } + + let fromRelease: Release | null = null + + if (compareWith) { + fromRelease = await this.findRelease(platform, compareWith) + if (!fromRelease) { + throw new Error(`Release ${platform}/${compareWith} not found`) + } + } else { + // Auto-detect previous release + fromRelease = await this.getPreviousRelease(toRelease) + if (!fromRelease) { + this.logger?.warn(`No previous release found for ${platform}/${version}`) + return null + } + } + + // Use origin/ prefix for git commands + return { + from: fromRelease, + to: toRelease, + commitRange: `origin/${fromRelease.branch}..origin/${toRelease.branch}`, + } + } + + /** + * List releases in a formatted way + */ + async listReleases(platform?: 'mobile' | 'extension'): Promise { + const releases = await this.scanReleases(platform) + + if (releases.length === 0) { + console.log('No releases found') + return + } + + // Group by platform if showing all + const grouped = releases.reduce( + (acc, release) => { + if (!acc[release.platform]) { + acc[release.platform] = [] + } + const platformReleases = acc[release.platform] + if (platformReleases) { + platformReleases.push(release) + } + return acc + }, + {} as Record, + ) + + for (const [plat, rels] of Object.entries(grouped)) { + console.log(`\n${plat.toUpperCase()} RELEASES:`) + + console.log('─'.repeat(40)) + + for (const rel of rels.slice(0, 10)) { + // Show only latest 10 + + console.log(` ${rel.version.padEnd(10)} → ${rel.branch}`) + } + + if (rels.length > 10) { + console.log(` ... and ${rels.length - 10} more`) + } + } + } + + /** + * Get commits between two releases + */ + async getReleaseCommits(comparison: ReleaseComparison): Promise { + const result = await $`git -C ${this.repoPath} log ${comparison.commitRange} --oneline`.text() + return result + } + + /** + * Parse a release branch name into a Release object + */ + private parseReleaseBranch(branch: string): Release | null { + // Match patterns like: + // origin/releases/mobile/1.60 + // origin/releases/extension/1.30.0 + // Safe regex pattern - matches controlled git branch names only + // eslint-disable-next-line security/detect-unsafe-regex -- Controlled pattern matching git branch names with bounded quantifiers + const match = branch.match(/^origin\/releases\/(mobile|extension)\/(\d+)\.(\d+)(?:\.(\d+))?(?:\.(.+))?$/) + + if (!match) { + return null + } + + const [, platform, major, minor, patch, prerelease] = match + if (!platform || !major || !minor) { + return null + } + const version = patch + ? `${major}.${minor}.${patch}${prerelease ? `.${prerelease}` : ''}` + : `${major}.${minor}${prerelease ? `.${prerelease}` : ''}` + + return { + platform: platform as 'mobile' | 'extension', + version, + branch: branch.replace('origin/', ''), + major: parseInt(major, 10), + minor: parseInt(minor, 10), + patch: parseInt(patch ?? '0', 10), + prerelease: prerelease ?? undefined, + } + } + + /** + * Compare two versions (returns positive if a > b, negative if a < b, 0 if equal) + */ + private compareVersions(a: Release, b: Release): number { + // Compare major + if (a.major !== b.major) { + return a.major - b.major + } + + // Compare minor + if (a.minor !== b.minor) { + return a.minor - b.minor + } + + // Compare patch + if (a.patch !== b.patch) { + return a.patch - b.patch + } + + // Compare prerelease (if both have it) + if (a.prerelease && b.prerelease) { + return a.prerelease.localeCompare(b.prerelease) + } + + // Version without prerelease is greater than with prerelease + if (a.prerelease && !b.prerelease) { + return -1 + } + if (!a.prerelease && b.prerelease) { + return 1 + } + + return 0 + } +} + +/** + * Parse a release identifier like "mobile/1.60" or "extension/1.30.0" + */ +export function parseReleaseIdentifier( + identifier: string, +): { platform: 'mobile' | 'extension'; version: string } | null { + const match = identifier.match(/^(mobile|extension)\/(.+)$/) + if (!match) { + return null + } + + const platform = match[1] as 'mobile' | 'extension' + const version = match[2] + if (!version) { + return null + } + + return { + platform, + version, + } +} diff --git a/apps/cli/src/lib/stream-handler.ts b/apps/cli/src/lib/stream-handler.ts new file mode 100644 index 00000000000..9a262b12e25 --- /dev/null +++ b/apps/cli/src/lib/stream-handler.ts @@ -0,0 +1,35 @@ +/** biome-ignore-all lint/suspicious/noConsole: cli tool */ +import type { StreamChunk } from '@universe/cli/src/lib/ai-provider' + +/** + * Stream Handler for CLI Output + * + * Separates the concern of handling streaming output to console. + * Accumulates full response while streaming to stdout for user feedback. + */ + +/** + * Writes stream chunks to console and accumulates full response + * @param stream - Async generator of stream chunks + * @returns Complete accumulated text + */ +export async function writeStreamToConsole(stream: AsyncGenerator): Promise { + let fullText = '' + + for await (const chunk of stream) { + if (chunk.text) { + fullText += chunk.text + // Write to stdout for real-time feedback + process.stdout.write(chunk.text) + } + + if (chunk.isComplete) { + // New line after streaming completes + // eslint-disable-next-line no-console + console.log() + break + } + } + + return fullText +} diff --git a/apps/cli/src/lib/team-members.ts b/apps/cli/src/lib/team-members.ts new file mode 100644 index 00000000000..91a7fb39f04 --- /dev/null +++ b/apps/cli/src/lib/team-members.ts @@ -0,0 +1,37 @@ +import { $ } from 'bun' + +export interface TeamMember { + login: string + name: string | null +} + +/** + * Fetches team members from a GitHub organization team + * Returns members with their login and display name + */ +export async function fetchTeamMembers(org: string, teamSlug: string): Promise { + try { + // Get team members (just logins) + const membersResult = await $`gh api /orgs/${org}/teams/${teamSlug}/members --jq '.[].login'`.text() + const logins = membersResult.split('\n').filter(Boolean) + + // Fetch detailed user info for each member + const members: TeamMember[] = [] + for (const login of logins) { + try { + const userResult = await $`gh api /users/${login} --jq '{login: .login, name: .name}'`.text() + const userData = JSON.parse(userResult) as TeamMember + members.push(userData) + } catch { + // If fetching user details fails, just use the login + members.push({ login, name: null }) + } + } + + return members + } catch (error) { + throw new Error(`Failed to fetch members for team ${teamSlug}. Ensure gh CLI is authenticated and team exists.`, { + cause: error, + }) + } +} diff --git a/apps/cli/src/lib/team-resolver.ts b/apps/cli/src/lib/team-resolver.ts new file mode 100644 index 00000000000..b765f94c146 --- /dev/null +++ b/apps/cli/src/lib/team-resolver.ts @@ -0,0 +1,156 @@ +import { $ } from 'bun' + +interface UserResolution { + username?: string + emails: string[] +} + +interface GitHubCommitSearchItem { + commit?: { + author?: { + email?: string + } + } +} + +interface GitHubCommitSearchResult { + items?: GitHubCommitSearchItem[] +} + +interface GitHubUserData { + id?: number + email?: string + login?: string +} + +/** + * Resolves a team reference or username to email addresses + * Supports: + * - GitHub teams: @org/team + * - GitHub usernames: alice, bob + * - Email addresses: alice@example.com + * - Mixed: @org/team,alice,bob@example.com + */ +export async function resolveTeam(teamRef: string): Promise<{ emails: string[]; usernames: string[] }> { + const parts = teamRef + .split(',') + .map((p) => p.trim()) + .filter(Boolean) + const allEmails: string[] = [] + const allUsernames: string[] = [] + + for (const part of parts) { + if (part.startsWith('@')) { + // GitHub team reference + const { emails, usernames } = await resolveGitHubTeam(part) + allEmails.push(...emails) + allUsernames.push(...usernames) + } else if (part.includes('@')) { + // Already an email + allEmails.push(part) + } else { + // GitHub username + const resolution = await resolveUserToEmail(part) + allEmails.push(...resolution.emails) + if (resolution.username) { + allUsernames.push(resolution.username) + } + } + } + + return { + emails: [...new Set(allEmails)], // Remove duplicates + usernames: [...new Set(allUsernames)], + } +} + +async function resolveGitHubTeam(teamRef: string): Promise<{ emails: string[]; usernames: string[] }> { + // Parse @org/team format + const [org, team] = teamRef.slice(1).split('/') + + if (!org || !team) { + throw new Error(`Invalid team reference: ${teamRef}. Expected format: @org/team`) + } + + try { + // Get team members + const membersResult = await $`gh api /orgs/${org}/teams/${team}/members --jq '.[].login'`.text() + const members = membersResult.split('\n').filter(Boolean) + + // Resolve each member to emails + const emails: string[] = [] + const usernames: string[] = [] + + for (const member of members) { + const resolution = await resolveUserToEmail(member) + emails.push(...resolution.emails) + if (resolution.username) { + usernames.push(resolution.username) + } + } + + return { emails, usernames } + } catch (_error) { + throw new Error(`Failed to resolve team ${teamRef}. Ensure gh CLI is authenticated and team exists.`) + } +} + +async function resolveUserToEmail(user: string): Promise { + // If it contains @, it's already an email + if (user.includes('@')) { + return { emails: [user] } + } + + // Otherwise, treat it as a GitHub username + try { + const userDataResult = await $`gh api /users/${user}`.text() + const userData = JSON.parse(userDataResult) as GitHubUserData + + // Get user's email from their profile (if public) + const emails: string[] = [] + if (userData.email) { + emails.push(userData.email) + } + + // Also try to get commit email by searching for their commits + try { + const searchResult = await $`gh api /search/commits?q=author:${user}&per_page=5`.text() + const searchData = JSON.parse(searchResult) as GitHubCommitSearchResult + + if (searchData.items && searchData.items.length > 0) { + const commitEmails = searchData.items + .map((item) => item.commit?.author?.email) + .filter((email): email is string => typeof email === 'string' && !emails.includes(email)) + + emails.push(...commitEmails) + } + } catch { + // Commit search failed, continue with what we have + } + + if (emails.length === 0 && userData.id) { + // Fallback: use GitHub's noreply email format + emails.push(`${userData.id}+${user}@users.noreply.github.com`) + } + + return { username: user, emails } + } catch (_error) { + throw new Error(`Failed to resolve GitHub username "${user}". User may not exist or gh CLI is not configured.`) + } +} + +/** + * Detects repository from git remote + */ +export async function detectRepository(): Promise<{ owner: string; name: string } | null> { + try { + const remote = await $`git config --get remote.origin.url`.text() + const match = remote.trim().match(/github\.com[:/]([^/]+)\/([^/.]+)(\.git)?$/) + if (match?.[1] && match[2]) { + return { owner: match[1], name: match[2] } + } + } catch { + // Not a git repo or no remote configured + } + return null +} diff --git a/apps/cli/src/lib/trivial-files.ts b/apps/cli/src/lib/trivial-files.ts new file mode 100644 index 00000000000..cd4b0b814d9 --- /dev/null +++ b/apps/cli/src/lib/trivial-files.ts @@ -0,0 +1,74 @@ +/** + * Utility for identifying trivial files that should be filtered from changelog analysis + */ + +const TRIVIAL_PATTERNS = [ + // Lockfiles + /package-lock\.json$/, + /yarn\.lock$/, + /pnpm-lock\.yaml$/, + /bun\.lockb$/, + /Cargo\.lock$/, + /Gemfile\.lock$/, + /composer\.lock$/, + /poetry\.lock$/, + + // Snapshots + /\.snap$/, + /\.snap\.\w+$/, + /\/__snapshots__\//, + /\.snapshot$/, + /\.snapshot\.json$/, + + // Generated files + /\.generated\./, + /\/__generated__\//, + /\/generated\//, + /codegen\//, + + // Build artifacts + /^dist\//, + /^build\//, + /\.next\//, + /\.turbo\//, + /^out\//, + + // Test artifacts + /^coverage\//, + /\.lcov$/, + /\.nyc_output\//, + /test-results\//, + + // Large data files + /fixtures\//, + /\/__fixtures__\//, + /testdata\//, + + // Binary and media files + /\.(png|jpg|jpeg|gif|ico|svg|webp|pdf|zip|tar|gz)$/i, + + // IDE and OS files + /\.DS_Store$/, + /Thumbs\.db$/, + /\.swp$/, + /\.swo$/, + + // Other + /node_modules\//, + /vendor\//, + /\.pnp\./, +] + +/** + * Check if a file path represents a trivial file that should be filtered + */ +export function isTrivialFile(path: string): boolean { + return TRIVIAL_PATTERNS.some((pattern) => pattern.test(path)) +} + +/** + * Filter an array of file paths to exclude trivial files + */ +export function filterTrivialFiles(paths: string[]): string[] { + return paths.filter((path) => !isTrivialFile(path)) +} diff --git a/apps/cli/src/prompts/bug-bisect.md b/apps/cli/src/prompts/bug-bisect.md new file mode 100644 index 00000000000..fbec2db123e --- /dev/null +++ b/apps/cli/src/prompts/bug-bisect.md @@ -0,0 +1,83 @@ +You are analyzing commits in a release to identify which commit likely introduced a bug. Your goal is to carefully examine all commits and PRs in the release range and rank them by likelihood of introducing the bug described. + +## Bug Description + +{{BUG_DESCRIPTION}} + +## Release Context + +- **Platform:** {{PLATFORM}} +- **Release:** {{RELEASE_TO}} +- **Comparing with:** {{RELEASE_FROM}} + +## Your Task + +Analyze ALL commits and pull requests in the release range. For each commit/PR, determine how likely it is that it introduced the bug described above. Consider: + +1. **Direct relevance**: Does the commit modify code that directly relates to the bug description? +2. **Indirect impact**: Could changes in this commit cause side effects that lead to the bug? +3. **Pattern matching**: Do file paths, function names, or component names match keywords in the bug description? +4. **Timing**: If the bug appeared in this release, commits in this range are prime suspects +5. **Related PRs**: Multiple commits from the same PR may be related and should be considered together + +## Output Format + +You MUST return a valid JSON object with the following structure: + +```json +{ + "suspiciousCommits": [ + { + "sha": "full commit SHA", + "confidence": 0.85, + "reasoning": "Brief explanation of why this commit is suspicious, mentioning specific files/functions/modules changed that relate to the bug", + "relatedPR": 1234 + } + ], + "summary": "Brief summary of findings: how many commits analyzed, how many suspicious commits found, and overall assessment", + "totalCommitsAnalyzed": 247, + "releaseContext": { + "from": "{{RELEASE_FROM}}", + "to": "{{RELEASE_TO}}", + "platform": "{{PLATFORM}}" + } +} +``` + +## Requirements + +1. **Rank commits by confidence**: Order `suspiciousCommits` array from highest to lowest confidence +2. **Confidence scores**: Use 0.0-1.0 scale where: + - 0.9-1.0: Very likely culprit (direct match, clear causation) + - 0.7-0.9: Likely related (strong indirect connection) + - 0.5-0.7: Possibly related (weak connection, worth investigating) + - < 0.5: Unlikely (exclude from results) +3. **Return top 10-20 commits**: Focus on the most suspicious commits, not all commits +4. **Include reasoning**: Each commit must have a clear explanation of why it's suspicious +5. **Match PRs**: If a commit is part of a PR, include the PR number in `relatedPR` +6. **Be specific**: Reference specific files, functions, or components in your reasoning + +## Analysis Process + +Before generating your output, analyze the commit data systematically: + +1. **Scan for keywords**: Look for file paths, function names, or component names that match keywords in the bug description +2. **Review PR descriptions**: PR bodies often contain context about what changed and why +3. **Check related commits**: Commits that touch similar files or components may be related +4. **Consider the full context**: Sometimes the bug is caused by an interaction between multiple changes + +## Important Notes + +- Analyze ALL commits provided, even if the context is truncated due to token limits +- If multiple commits from the same PR are suspicious, include them all but note they're related +- Be thorough but focused - prioritize commits with the strongest connection to the bug +- Your reasoning should help developers quickly understand why each commit is suspicious + +Here is the commit data you need to analyze: + + +{{COMMIT_DATA}} + + +Now analyze the commits and return your JSON response with ranked suspicious commits. + diff --git a/apps/cli/src/prompts/release-changelog.md b/apps/cli/src/prompts/release-changelog.md new file mode 100644 index 00000000000..d1306c92c52 --- /dev/null +++ b/apps/cli/src/prompts/release-changelog.md @@ -0,0 +1,74 @@ +You are creating a technical changelog for engineers at Uniswap Labs. Your goal is to analyze commit data and write a changelog that explains what shipped to colleagues in a direct, conversational, and factual manner - similar to Linear's changelog style. + +**CRITICAL: Focus on what actually changed, not editorial judgments** +- Describe the specific changes that were made +- Avoid inferential language like "finally," "now works properly," or "is finally real" +- Don't make assumptions about timeline, quality, or significance +- State what changed factually without commentary + +Before writing the changelog, do your analysis and planning work in tags inside your thinking block. It's OK for this section to be quite long. Include: + +1. **Commit Extraction**: First, go through the commit data and list out all the key commits with their PR numbers and descriptions to keep them top of mind. + +2. **Pattern Identification**: Read through all the commits and identify major themes or areas of work (aim for 4-7 themes). Look for related PRs that address similar functionality, components, or types of changes. + +3. **Grouping Strategy**: For each theme you identify, list which specific PRs belong to it and what the common thread is. + +4. **Factual Focus**: For each group, identify the specific technical changes made without making inferences about their importance or timeline. + +5. **Structure Planning**: Plan how you'll organize each theme section and what specific language you'll use - some may need more technical detail, others may be straightforward. + +After your planning, write the changelog using this exact structure: + +## Release Overview +- [X] PRs, [Y] contributors +- [One paragraph listing main work areas. Keep it factual and brief.] + +## Major Themes + +For each major pattern (4-7 themes total): + +### [Direct, Clear Theme Name] + +[2-3 paragraphs explaining what changed. Focus on the actual modifications made to the codebase. Vary your structure - not every section needs the same format. Mix technical details with broader changes as appropriate.] + +
+All related PRs (X total) + +- #123: Brief description +- #124: What changed +- #125: Technical detail +[... all PRs for this theme ...] +
+ +**Contributors:** @name1, @name2, @name3 + +**Writing Guidelines:** +- Write like a human, not a content generator +- Vary sentence starters - don't always begin with "The team" or "This release" +- Use present tense for current state: "The extension locks automatically after..." +- Be conversational but professional: "The old `isAddress()` function is gone - replaced with explicit validators" +- Include technical specifics when developers would care about them (function names, specific fixes, workarounds) +- Natural transitions or none at all - sometimes jump between topics +- Some features get detailed explanations, others just need a line or two + +**Avoid:** +- Editorial language: "smart decisions," "clever solutions," "finally," "now works properly" +- Justification: "This is important because..." +- Hedging: "presumably," "apparently" +- Obvious transitions: "Worth noting," "It's important to mention," "The interesting part" + +**Include When Relevant:** +- Specific function names, APIs, or technical components that changed +- Workarounds or gotchas developers should know about +- Patterns that are being reused across the codebase +- Platform-specific considerations +- Breaking changes or migrations + +Your final output should consist only of the changelog in the specified format and should not duplicate or rehash any of the analysis and planning work you did in the thinking block. + +Here is the commit data you need to analyze: + + +{{COMMIT_DATA}} + diff --git a/apps/cli/src/prompts/team-digest.md b/apps/cli/src/prompts/team-digest.md new file mode 100644 index 00000000000..d3271fa0c53 --- /dev/null +++ b/apps/cli/src/prompts/team-digest.md @@ -0,0 +1,102 @@ +You will analyze repository commit data to create a team digest for engineers at Uniswap Labs. Your goal is to transform raw development activity into a readable summary that explains what the team accomplished during a specific time period. + +## Your Task + +Create a structured team digest that describes what was built, added, or modified based on the commit data. Write factually about the work completed without making assumptions about completion status or quality. + +## Critical Requirements + +Follow these requirements strictly: + +1. **Use GitHub usernames only** - Format as @username, never use or invent human names +2. **Describe changes factually** - State what was built/added/modified without making quality judgments +3. **Avoid completion language** - Do not use "complete," "finished," "fully implemented," or "comprehensive" since you cannot determine completion status from commit messages alone +4. **Avoid embellished language** - Do not use "significantly improved," "enhanced," or "optimized" unless these are objectively measurable facts explicitly stated in the commit messages +5. **Right-size technical detail** - Include key components and patterns, skip implementation minutiae +6. **Focus on outcomes first** - Lead with what changed for users/developers, then explain how +7. **Minimize code content** - Be very selective about including code snippets or technical implementation details. Focus on impact and readability. Only include essential code-related information when truly necessary for understanding the work's significance +8. **Improve readability** - Limit to 4-6 themes maximum to avoid monotony. Vary your sentence structure and integrate contributor mentions naturally rather than starting every paragraph with "@person did xyz" + +## Analysis Process + +Before writing your digest, complete a thorough analysis inside tags within your thinking block. It's OK for this section to be quite long. Include: + +1. **Extract All Commit Messages**: Quote every single commit message verbatim from the data, one by one +2. **Extract Contributors**: Systematically go through the data and list all GitHub usernames (format as @username), counting them as you go +3. **Group into 4-6 Themes**: Organize commits into coherent themes based on functionality or area of work. For each theme: + - Theme name + - Specific commits that belong (quote relevant messages verbatim) + - Contributors who worked on it +4. **Plan Technical Details**: For each theme, identify: + - Main outcome or change (focus on impact, not implementation) + - 1-2 key technical details worth mentioning only if they help explain impact + - Avoid code snippets unless absolutely essential +5. **Structure Planning**: Write your planned theme names and brief descriptions +6. **Readability Check**: Review your planned descriptions to ensure you: + - Have 4-6 themes maximum for better flow + - Vary sentence structure (don't start every paragraph with contributor names) + - Integrate contributor mentions naturally throughout the narrative + - State facts from commits without making quality assessments + - Avoid completion language + - Focus on outcomes over technical implementation +7. **Requirements Verification**: Systematically check each planned theme description against all 8 critical requirements listed above, going through them one by one to ensure compliance + +## Output Structure + +Write your digest following this exact structure: + +### Team Digest: [Date Range] + +[Opening paragraph: Main areas of work. Be specific but concise - 2-3 sentences maximum.] + +### What Was Shipped + +For each theme (4-6 total): + +#### [Clear Theme Name] + +[2-3 paragraphs explaining: +- Paragraph 1: What was built and why it matters (outcome and impact) +- Paragraph 2: Key approach - mention important components only when relevant for understanding impact +- Paragraph 3 (if needed): Integration points or collaboration details + +Mention contributors naturally throughout, varying sentence structure] + +### Technical Highlights + +[3-5 bullet points of technically interesting work that other engineers would want to know about. Focus on reusable patterns, architectural decisions, or important changes. Only include items that add significant value and aren't duplicative of what was stated above.] + +## Example Output Structure + +### Team Digest: March 1-15, 2024 + +The team focused on expanding notification capabilities, refactoring data layer components, and improving test infrastructure. + +### What Was Shipped + +#### Notification System Development + +A new notification system now polls the backend and manages dismissal state locally, allowing users to receive timely updates about important events. The system provides options for different notification types and integrates with existing user preferences through the settings panel. + +Working primarily on the core functionality, @alice built the polling mechanism and state management, while @bob handled the integration work that allows users to configure which notifications they receive and how they're delivered. + +#### Data Layer Refactoring + +The data access layer underwent restructuring with multiple API clients consolidated into a unified service. This change provides more consistent error handling and simplifies how the application manages data across different features. + +@charlie led this refactoring effort, which resulted in a unified error handling pattern that reduces code duplication across components. + +### Technical Highlights + +• New notification provider can be easily integrated into other applications +• Unified error handling pattern reduces code duplication across components +• Test suite now runs 40% faster due to infrastructure improvements +• New data fetching approach simplifies component logic and improves testability + +Your final output should consist only of the structured team digest following the format above, without duplicating or rehashing any of the analysis work you completed in your thinking block. + +Here is the commit data you need to analyze: + + +{{COMMIT_DATA}} + diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx new file mode 100644 index 00000000000..e0ddb98230c --- /dev/null +++ b/apps/cli/src/ui/App.tsx @@ -0,0 +1,175 @@ +import type { OrchestratorConfig } from '@universe/cli/src/core/orchestrator' +import type { Release } from '@universe/cli/src/lib/release-scanner' +import { AppStateProvider, type TeamFilter, useAppState } from '@universe/cli/src/ui/hooks/useAppState' +import { BugBisectResultsScreen } from '@universe/cli/src/ui/screens/BugBisectResultsScreen' +import { BugInputScreen } from '@universe/cli/src/ui/screens/BugInputScreen' +import { ConfigReview } from '@universe/cli/src/ui/screens/ConfigReview' +import { ExecutionScreen } from '@universe/cli/src/ui/screens/ExecutionScreen' +import { ReleaseSelector } from '@universe/cli/src/ui/screens/ReleaseSelector' +import { ResultsScreen } from '@universe/cli/src/ui/screens/ResultsScreen' +import { TeamSelectorScreen } from '@universe/cli/src/ui/screens/TeamSelectorScreen' +import { WelcomeScreen } from '@universe/cli/src/ui/screens/WelcomeScreen' +import { useCallback, useEffect } from 'react' + +function AppContent(): JSX.Element { + const { state, dispatch } = useAppState() + + // Clear terminal on initial mount + useEffect(() => { + process.stdout.write('\x1Bc') // VT100 clear screen and scrollback + }, []) + const handleWelcomeContinue = (mode: 'release-changelog' | 'team-digest' | 'changelog' | 'bug-bisect'): void => { + // Route based on analysis mode + switch (mode) { + case 'release-changelog': + dispatch({ type: 'SET_SCREEN', screen: 'release-select' }) + break + case 'bug-bisect': + dispatch({ type: 'SET_SCREEN', screen: 'release-select' }) + break + case 'team-digest': + dispatch({ type: 'SET_SCREEN', screen: 'team-select' }) + break + case 'changelog': + // Skip to config review for custom analysis + dispatch({ type: 'SET_SCREEN', screen: 'config-review' }) + break + } + } + + const handleReleaseSelect = (_release: Release, _comparison: Release | null): void => { + // If bug-bisect mode, go to bug input screen; otherwise go to config review + if (state.analysisMode === 'bug-bisect') { + dispatch({ type: 'SET_SCREEN', screen: 'bug-input' }) + } else { + dispatch({ type: 'SET_SCREEN', screen: 'config-review' }) + } + } + + const handleBugInputContinue = (): void => { + dispatch({ type: 'SET_SCREEN', screen: 'config-review' }) + } + + const handleBugInputBack = (): void => { + dispatch({ type: 'SET_SCREEN', screen: 'release-select' }) + } + + const handleTeamSelect = (_teamFilter: TeamFilter | null): void => { + dispatch({ type: 'SET_SCREEN', screen: 'config-review' }) + } + + const handleConfigConfirm = (config: OrchestratorConfig): void => { + dispatch({ type: 'UPDATE_CONFIG', config }) + dispatch({ type: 'SET_SCREEN', screen: 'execution' }) + } + + const handleExecutionComplete = useCallback( + (results: Record): void => { + // Transform orchestrator results to app state format + // Orchestrator returns { analysis: "markdown content", ... } or JSON for bug-bisect + const changelog = typeof results.analysis === 'string' ? results.analysis : JSON.stringify(results, null, 2) + + dispatch({ type: 'SET_RESULTS', results: { changelog, metadata: results } }) + // Route to bug-bisect results screen if in bug-bisect mode + if (state.analysisMode === 'bug-bisect') { + dispatch({ type: 'SET_SCREEN', screen: 'results' }) + } else { + dispatch({ type: 'SET_SCREEN', screen: 'results' }) + } + }, + [dispatch, state.analysisMode], + ) + + const handleExecutionError = useCallback( + (_error: Error): void => { + dispatch({ type: 'SET_EXECUTION_STATE', state: 'error' }) + // Could navigate to error screen or show error in execution screen + }, + [dispatch], + ) + + const handleBack = (): void => { + // Determine previous screen based on current screen and mode + if (state.screen === 'config-review') { + // Go back to appropriate selector based on mode + switch (state.analysisMode) { + case 'release-changelog': + dispatch({ type: 'SET_SCREEN', screen: 'release-select' }) + break + case 'bug-bisect': + dispatch({ type: 'SET_SCREEN', screen: 'bug-input' }) + break + case 'team-digest': + dispatch({ type: 'SET_SCREEN', screen: 'team-select' }) + break + case 'changelog': + dispatch({ type: 'SET_SCREEN', screen: 'welcome' }) + break + } + } else if (state.screen === 'bug-input') { + dispatch({ type: 'SET_SCREEN', screen: 'release-select' }) + } else if (state.screen === 'release-select' || state.screen === 'team-select') { + dispatch({ type: 'SET_SCREEN', screen: 'welcome' }) + } else if (state.screen === 'execution' || state.screen === 'results') { + dispatch({ type: 'SET_SCREEN', screen: 'config-review' }) + } + } + + const handleRestart = (): void => { + dispatch({ type: 'SET_SCREEN', screen: 'welcome' }) + dispatch({ type: 'SELECT_RELEASE', release: null }) + dispatch({ type: 'SET_COMPARISON_RELEASE', release: null }) + dispatch({ type: 'SET_RESULTS', results: null }) + dispatch({ type: 'SET_EXECUTION_STATE', state: 'idle' }) + } + + switch (state.screen) { + case 'welcome': + return + case 'release-select': + return + case 'bug-input': + return + case 'team-select': + return + case 'config-review': + return + case 'execution': + return ( + + ) + case 'results': + // Use BugBisectResultsScreen for bug-bisect mode, otherwise ResultsScreen + if (state.analysisMode === 'bug-bisect') { + return ( + + ) + } + return ( + + ) + default: + return + } +} + +export function App(): JSX.Element { + return ( + + + + ) +} diff --git a/apps/cli/src/ui/components/Banner.tsx b/apps/cli/src/ui/components/Banner.tsx new file mode 100644 index 00000000000..09c3d17375a --- /dev/null +++ b/apps/cli/src/ui/components/Banner.tsx @@ -0,0 +1,30 @@ +import { Box, Text } from 'ink' +import Gradient from 'ink-gradient' + +const BANNER_ART = ` + ██╗ ██╗███╗ ██╗██╗██╗ ██╗███████╗██████╗ ███████╗███████╗ + ██║ ██║████╗ ██║██║██║ ██║██╔════╝██╔══██╗██╔════╝██╔════╝ + ██║ ██║██╔██╗ ██║██║██║ ██║█████╗ ██████╔╝███████╗█████╗ + ██║ ██║██║╚██╗██║██║╚██╗ ██╔╝██╔══╝ ██╔══██╗╚════██║██╔══╝ + ╚██████╔╝██║ ╚████║██║ ╚████╔╝ ███████╗██║ ██║███████║███████╗ (cli) + ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝╚══════╝╚══════╝ +` + +interface BannerProps { + subtitle?: string +} + +export function Banner({ subtitle }: BannerProps): JSX.Element { + return ( + + {BANNER_ART} + {subtitle && ( + + + {subtitle} + + + )} + + ) +} diff --git a/apps/cli/src/ui/components/Box.tsx b/apps/cli/src/ui/components/Box.tsx new file mode 100644 index 00000000000..e988e17715f --- /dev/null +++ b/apps/cli/src/ui/components/Box.tsx @@ -0,0 +1,18 @@ +import { Box as InkBox, Text } from 'ink' +import type { ReactNode } from 'react' + +interface BoxProps { + children: ReactNode + title?: string + borderColor?: string + padding?: number +} + +export function Box({ children, title, borderColor, padding = 1 }: BoxProps): JSX.Element { + return ( + + {title && {title}} + {children} + + ) +} diff --git a/apps/cli/src/ui/components/ChangelogPreview.tsx b/apps/cli/src/ui/components/ChangelogPreview.tsx new file mode 100644 index 00000000000..48315cd9a02 --- /dev/null +++ b/apps/cli/src/ui/components/ChangelogPreview.tsx @@ -0,0 +1,20 @@ +import { Box as InkBox, Text } from 'ink' + +interface ChangelogPreviewProps { + changelog: string +} + +export function ChangelogPreview({ changelog }: ChangelogPreviewProps): JSX.Element { + // Split into lines and render + const lines = changelog.split('\n') + + return ( + + Changelog Preview + + {lines.map((line, index) => ( + {line} + ))} + + ) +} diff --git a/apps/cli/src/ui/components/FormField.tsx b/apps/cli/src/ui/components/FormField.tsx new file mode 100644 index 00000000000..68f2aaaca78 --- /dev/null +++ b/apps/cli/src/ui/components/FormField.tsx @@ -0,0 +1,26 @@ +import { Box, Text } from 'ink' +import type { ReactNode } from 'react' + +interface FormFieldProps { + children: ReactNode + focused?: boolean + helpText?: string + marginLeft?: number +} + +/** + * Wrapper component for form fields that provides consistent styling + * and focus handling. Composable with Toggle, TextInput, NumberInput, etc. + */ +export function FormField({ children, focused, helpText, marginLeft = 0 }: FormFieldProps): JSX.Element { + return ( + + {children} + {helpText && focused && ( + + {helpText} + + )} + + ) +} diff --git a/apps/cli/src/ui/components/NumberInput.tsx b/apps/cli/src/ui/components/NumberInput.tsx new file mode 100644 index 00000000000..9e327e9ad9d --- /dev/null +++ b/apps/cli/src/ui/components/NumberInput.tsx @@ -0,0 +1,39 @@ +import { colors } from '@universe/cli/src/ui/utils/colors' +import { Box, Text } from 'ink' + +interface NumberInputProps { + label: string + value: number + onChange: (value: number) => void + focused?: boolean + isEditing?: boolean + editValue?: string + min?: number + max?: number + step?: number +} + +export function NumberInput({ + label, + value, + onChange: _onChange, + focused = false, + isEditing = false, + editValue, + min: _min, + max: _max, + step: _step = 1, +}: NumberInputProps): JSX.Element { + const displayValue = isEditing && editValue !== undefined ? editValue : value + + return ( + + + {focused ? '❯ ' : ' '} + {label}: {displayValue} + {isEditing && (Enter to save, Esc to cancel, type digits)} + {focused && !isEditing && (↑↓←→ to adjust, Enter to edit)} + + + ) +} diff --git a/apps/cli/src/ui/components/ProgressIndicator.tsx b/apps/cli/src/ui/components/ProgressIndicator.tsx new file mode 100644 index 00000000000..4b7a3f75344 --- /dev/null +++ b/apps/cli/src/ui/components/ProgressIndicator.tsx @@ -0,0 +1,75 @@ +import type { ProgressEvent, ProgressStage } from '@universe/cli/src/ui/services/orchestrator-service' +import { colors } from '@universe/cli/src/ui/utils/colors' +import { Box, Text } from 'ink' + +interface Stage { + key: ProgressStage + label: string +} + +const stages: Stage[] = [ + { key: 'collecting', label: 'Collecting data' }, + { key: 'analyzing', label: 'Analyzing with AI' }, + { key: 'delivering', label: 'Delivering results' }, +] + +interface ProgressIndicatorProps { + currentStage: ProgressStage + message?: string + cacheInfo?: ProgressEvent['cacheInfo'] +} + +export function ProgressIndicator({ currentStage, message, cacheInfo }: ProgressIndicatorProps): JSX.Element { + const currentIndex = stages.findIndex((s) => s.key === currentStage) + + const getCacheLabel = (cacheInfoItem: ProgressEvent['cacheInfo']): string => { + if (!cacheInfoItem) { + return '' + } + const typeLabel = cacheInfoItem.type === 'commits' ? 'commits' : cacheInfoItem.type === 'prs' ? 'PRs' : 'stats' + return `(cached: ${cacheInfoItem.count} ${typeLabel})` + } + + return ( + + {stages.map((stage, index) => { + const isComplete = index < currentIndex + const isCurrent = index === currentIndex && currentStage !== 'idle' && currentStage !== 'error' + + let icon = '○' + let color = 'gray' + + if (isComplete) { + icon = '●' + color = 'green' + } else if (isCurrent) { + icon = '◉' + color = colors.primary + } + + // Show cache info for collecting stage when it's current or complete + const showCacheInfo = cacheInfo && stage.key === 'collecting' && (isCurrent || isComplete) + + return ( + + {icon} {stage.label} + {showCacheInfo && ( + + {' '} + {getCacheLabel(cacheInfo)} + + )} + {isCurrent && !showCacheInfo && '...'} + + ) + })} + {message && ( + + + {message} + + + )} + + ) +} diff --git a/apps/cli/src/ui/components/ReleaseList.tsx b/apps/cli/src/ui/components/ReleaseList.tsx new file mode 100644 index 00000000000..04911f578fa --- /dev/null +++ b/apps/cli/src/ui/components/ReleaseList.tsx @@ -0,0 +1,34 @@ +import type { Release } from '@universe/cli/src/lib/release-scanner' +import { colors } from '@universe/cli/src/ui/utils/colors' +import { formatBranch } from '@universe/cli/src/ui/utils/format' +import { Text } from 'ink' + +interface ReleaseListProps { + releases: Release[] + selectedIndex: number | null + platform?: 'mobile' | 'extension' +} + +export function ReleaseList({ releases, selectedIndex, platform }: ReleaseListProps): JSX.Element { + const filtered = platform ? releases.filter((r) => r.platform === platform) : releases + + if (filtered.length === 0) { + return No releases found + } + + return ( + <> + {filtered.map((release, index) => { + const isSelected = selectedIndex === index + const prefix = isSelected ? '→ ' : ' ' + + return ( + + {prefix} + {release.platform}/{release.version} ({formatBranch(release.branch)}) + + ) + })} + + ) +} diff --git a/apps/cli/src/ui/components/Select.tsx b/apps/cli/src/ui/components/Select.tsx new file mode 100644 index 00000000000..18b43a29332 --- /dev/null +++ b/apps/cli/src/ui/components/Select.tsx @@ -0,0 +1,38 @@ +import { colors } from '@universe/cli/src/ui/utils/colors' +import { Text } from 'ink' +import SelectInput, { type IndicatorProps, type ItemProps } from 'ink-select-input' + +interface SelectItem { + label: string + value: string +} + +interface SelectProps { + items: SelectItem[] + onSelect: (item: SelectItem) => void +} + +/** + * Themed SelectInput wrapper with Uniswap pink highlighting + */ +export function Select({ items, onSelect }: SelectProps): JSX.Element { + // Custom item component with pink color + const itemComponent = ({ isSelected, label }: ItemProps): JSX.Element => ( + + {isSelected ? '❯ ' : ' '} + {label} + + ) + + // Empty indicator component to disable default blue chevron + const indicatorComponent = (_props: IndicatorProps): JSX.Element => <> + + return ( + + ) +} diff --git a/apps/cli/src/ui/components/StatusBadge.tsx b/apps/cli/src/ui/components/StatusBadge.tsx new file mode 100644 index 00000000000..16e501d1b2c --- /dev/null +++ b/apps/cli/src/ui/components/StatusBadge.tsx @@ -0,0 +1,20 @@ +import { colors } from '@universe/cli/src/ui/utils/colors' +import { Text } from 'ink' + +type StatusType = 'success' | 'warning' | 'error' | 'info' + +interface StatusBadgeProps { + type: StatusType + children: React.ReactNode +} + +const statusColors: Record = { + success: colors.success, + warning: colors.warning, + error: colors.error, + info: colors.primary, +} + +export function StatusBadge({ type, children }: StatusBadgeProps): JSX.Element { + return {children} +} diff --git a/apps/cli/src/ui/components/TextInput.tsx b/apps/cli/src/ui/components/TextInput.tsx new file mode 100644 index 00000000000..c26dd66cdab --- /dev/null +++ b/apps/cli/src/ui/components/TextInput.tsx @@ -0,0 +1,38 @@ +import { colors } from '@universe/cli/src/ui/utils/colors' +import { Box, Text } from 'ink' + +interface TextInputProps { + label: string + value: string + onChange: (value: string) => void + focused?: boolean + isEditing?: boolean + editValue?: string + placeholder?: string +} + +export function TextInput({ + label, + value, + onChange: _onChange, + focused = false, + isEditing = false, + editValue, + placeholder = '', +}: TextInputProps): JSX.Element { + const displayValue = isEditing && editValue !== undefined ? editValue : value || placeholder + + return ( + + + {focused ? '❯ ' : ' '} + {label}:{' '} + + {displayValue} + + {isEditing && (Enter to save, Esc to cancel, type text)} + {focused && !isEditing && (Enter to edit)} + + + ) +} diff --git a/apps/cli/src/ui/components/Toggle.tsx b/apps/cli/src/ui/components/Toggle.tsx new file mode 100644 index 00000000000..38b1e6cf106 --- /dev/null +++ b/apps/cli/src/ui/components/Toggle.tsx @@ -0,0 +1,22 @@ +import { colors } from '@universe/cli/src/ui/utils/colors' +import { Text } from 'ink' + +interface ToggleProps { + label: string + checked: boolean + onToggle: () => void + focused?: boolean +} + +/** + * Toggle component - does not handle its own input + * Parent component should handle Enter/Space when this is focused + */ +export function Toggle({ label, checked, onToggle: _onToggle, focused = false }: ToggleProps): JSX.Element { + return ( + + {focused ? '❯ ' : ' '} + {checked ? '◉' : '○'} {label} + + ) +} diff --git a/apps/cli/src/ui/components/WindowedSelect.tsx b/apps/cli/src/ui/components/WindowedSelect.tsx new file mode 100644 index 00000000000..13d514398cc --- /dev/null +++ b/apps/cli/src/ui/components/WindowedSelect.tsx @@ -0,0 +1,101 @@ +import { colors } from '@universe/cli/src/ui/utils/colors' +import { Box, Text, useInput } from 'ink' +import { useCallback, useEffect, useState } from 'react' + +interface WindowedSelectItem { + label: string + value: string + data?: T +} + +interface WindowedSelectProps { + items: WindowedSelectItem[] + onSelect: (item: WindowedSelectItem) => void + onFocusChange?: (item: WindowedSelectItem | null) => void + limit?: number // Number of visible items (default: 10) +} + +const DEFAULT_LIMIT = 10 + +export function WindowedSelect({ + items, + onSelect, + onFocusChange, + limit = DEFAULT_LIMIT, +}: WindowedSelectProps): JSX.Element { + const [selectedIndex, setSelectedIndex] = useState(0) + const [startIndex, setStartIndex] = useState(0) + + // Notify parent when focused item changes + useEffect(() => { + if (onFocusChange) { + const focusedItem = items[selectedIndex] ?? null + onFocusChange(focusedItem) + } + }, [selectedIndex, items, onFocusChange]) + + // Calculate visible window + const endIndex = Math.min(startIndex + limit, items.length) + const visibleItems = items.slice(startIndex, endIndex) + const relativeSelectedIndex = selectedIndex - startIndex + + // Keep selected item in view when it moves outside the window + useEffect(() => { + if (selectedIndex < startIndex) { + // Selected item moved above visible window + setStartIndex(Math.max(0, selectedIndex)) + } else if (selectedIndex >= endIndex) { + // Selected item moved below visible window + setStartIndex(Math.max(0, selectedIndex - limit + 1)) + } + }, [selectedIndex, startIndex, endIndex, limit]) + + // Reset to top when items change + useEffect(() => { + setSelectedIndex(0) + setStartIndex(0) + }, []) + + // Handle keyboard input + useInput( + useCallback( + (input: string, key: { upArrow?: boolean; downArrow?: boolean; return?: boolean }) => { + if (key.upArrow && selectedIndex > 0) { + setSelectedIndex(selectedIndex - 1) + } else if (key.downArrow && selectedIndex < items.length - 1) { + setSelectedIndex(selectedIndex + 1) + } else if (key.return) { + const selectedItem = items[selectedIndex] + if (selectedItem) { + onSelect(selectedItem) + } + } + }, + [selectedIndex, items, onSelect], + ), + ) + + const hasMoreAbove = startIndex > 0 + const hasMoreBelow = endIndex < items.length + + return ( + + {hasMoreAbove && ... {startIndex} more above (use ↑ to scroll) ...} + {visibleItems.map((item, index) => { + const isSelected = index === relativeSelectedIndex + return ( + + {isSelected ? '❯ ' : ' '} + {item.label} + + ) + })} + {hasMoreBelow && ... {items.length - endIndex} more below (use ↓ to scroll) ...} + + + Selected: {selectedIndex + 1} of {items.length} (Enter to select) + + + + ) +} diff --git a/apps/cli/src/ui/hooks/useAnalysis.ts b/apps/cli/src/ui/hooks/useAnalysis.ts new file mode 100644 index 00000000000..e66406d0991 --- /dev/null +++ b/apps/cli/src/ui/hooks/useAnalysis.ts @@ -0,0 +1,53 @@ +import type { OrchestratorConfig } from '@universe/cli/src/core/orchestrator' +import { OrchestratorService, type ProgressEvent } from '@universe/cli/src/ui/services/orchestrator-service' +import { useCallback, useState } from 'react' + +interface UseAnalysisResult { + execute: (config: OrchestratorConfig) => Promise | null> + results: Record | null + progress: ProgressEvent | null + error: Error | null + isRunning: boolean +} + +export function useAnalysis(): UseAnalysisResult { + const [results, setResults] = useState | null>(null) + const [progress, setProgress] = useState(null) + const [error, setError] = useState(null) + const [isRunning, setIsRunning] = useState(false) + const [service] = useState(() => new OrchestratorService()) + + const execute = useCallback( + async (config: OrchestratorConfig): Promise | null> => { + try { + setIsRunning(true) + setError(null) + setProgress({ stage: 'idle' }) + setResults(null) + + const result = await service.execute(config, (event: ProgressEvent) => { + setProgress(event) + }) + + setResults(result) + setIsRunning(false) + return result + } catch (err) { + const errorObj = err instanceof Error ? err : new Error(String(err)) + setError(errorObj) + setIsRunning(false) + setProgress({ stage: 'error', message: errorObj.message }) + return null + } + }, + [service], + ) + + return { + execute, + results, + progress, + error, + isRunning, + } +} diff --git a/apps/cli/src/ui/hooks/useAppState.tsx b/apps/cli/src/ui/hooks/useAppState.tsx new file mode 100644 index 00000000000..f786dc9faed --- /dev/null +++ b/apps/cli/src/ui/hooks/useAppState.tsx @@ -0,0 +1,132 @@ +import type { OrchestratorConfig } from '@universe/cli/src/core/orchestrator' +import type { Release } from '@universe/cli/src/lib/release-scanner' +import { createContext, type ReactNode, useContext, useReducer } from 'react' + +export type Screen = + | 'welcome' + | 'release-select' + | 'team-select' + | 'config-review' + | 'execution' + | 'results' + | 'bug-input' + +export type AnalysisMode = 'release-changelog' | 'team-digest' | 'changelog' | 'bug-bisect' + +export interface TeamFilter { + teams?: string[] + usernames?: string[] + emails?: string[] +} + +export interface TeamMembersCache { + emails: string[] + usernames: string[] +} + +interface AppState { + screen: Screen + repository: { owner: string; name: string } | null + releases: Release[] + selectedRelease: Release | null + comparisonRelease: Release | null + analysisMode: AnalysisMode + bugDescription: string | null + teamFilter: TeamFilter | null + teamMembersCache: Record + timePeriod: string + config: Partial + executionState: 'idle' | 'running' | 'complete' | 'error' + results: { changelog: string; metadata: unknown } | null +} + +type AppAction = + | { type: 'SET_SCREEN'; screen: Screen } + | { type: 'SET_REPOSITORY'; repository: { owner: string; name: string } | null } + | { type: 'SET_RELEASES'; releases: Release[] } + | { type: 'SELECT_RELEASE'; release: Release | null } + | { type: 'SET_COMPARISON_RELEASE'; release: Release | null } + | { type: 'SET_ANALYSIS_MODE'; mode: AnalysisMode } + | { type: 'SET_BUG_DESCRIPTION'; description: string | null } + | { type: 'SET_TEAM_FILTER'; filter: TeamFilter | null } + | { type: 'CACHE_TEAM_MEMBERS'; teamSlug: string; members: TeamMembersCache } + | { type: 'SET_TIME_PERIOD'; period: string } + | { type: 'UPDATE_CONFIG'; config: Partial } + | { type: 'SET_EXECUTION_STATE'; state: 'idle' | 'running' | 'complete' | 'error' } + | { type: 'SET_RESULTS'; results: { changelog: string; metadata: unknown } | null } + +const initialState: AppState = { + screen: 'welcome', + repository: null, + releases: [], + selectedRelease: null, + comparisonRelease: null, + analysisMode: 'release-changelog', + bugDescription: null, + teamFilter: null, + teamMembersCache: {}, + timePeriod: '30 days ago', + config: {}, + executionState: 'idle', + results: null, +} + +function appReducer(state: AppState, action: AppAction): AppState { + switch (action.type) { + case 'SET_SCREEN': + return { ...state, screen: action.screen } + case 'SET_REPOSITORY': + return { ...state, repository: action.repository } + case 'SET_RELEASES': + return { ...state, releases: action.releases } + case 'SELECT_RELEASE': + return { ...state, selectedRelease: action.release } + case 'SET_COMPARISON_RELEASE': + return { ...state, comparisonRelease: action.release } + case 'SET_ANALYSIS_MODE': + return { ...state, analysisMode: action.mode } + case 'SET_BUG_DESCRIPTION': + return { ...state, bugDescription: action.description } + case 'SET_TEAM_FILTER': + return { ...state, teamFilter: action.filter } + case 'CACHE_TEAM_MEMBERS': + return { + ...state, + teamMembersCache: { + ...state.teamMembersCache, + [action.teamSlug]: action.members, + }, + } + case 'SET_TIME_PERIOD': + return { ...state, timePeriod: action.period } + case 'UPDATE_CONFIG': + return { ...state, config: { ...state.config, ...action.config } } + case 'SET_EXECUTION_STATE': + return { ...state, executionState: action.state } + case 'SET_RESULTS': + return { ...state, results: action.results } + default: + return state + } +} + +interface AppContextValue { + state: AppState + dispatch: React.Dispatch +} + +const AppContext = createContext(null) + +export function AppStateProvider({ children }: { children: ReactNode }): JSX.Element { + const [state, dispatch] = useReducer(appReducer, initialState) + + return {children} +} + +export function useAppState(): AppContextValue { + const context = useContext(AppContext) + if (!context) { + throw new Error('useAppState must be used within AppStateProvider') + } + return context +} diff --git a/apps/cli/src/ui/hooks/useEditableField.ts b/apps/cli/src/ui/hooks/useEditableField.ts new file mode 100644 index 00000000000..4f407921586 --- /dev/null +++ b/apps/cli/src/ui/hooks/useEditableField.ts @@ -0,0 +1,107 @@ +import { useCallback, useEffect, useState } from 'react' + +interface UseEditableFieldOptions { + value: T + onChange: (value: T) => void + focused: boolean + type?: 'text' | 'number' + min?: number + max?: number + step?: number + onEditStart?: () => void + onEditEnd?: () => void +} + +interface UseEditableFieldReturn { + isEditing: boolean + editValue: string + startEdit: () => void + saveEdit: () => void + cancelEdit: () => void + handleInput: (input: string, key: { backspace?: boolean; delete?: boolean }) => void +} + +/** + * Hook for managing editable field state and keyboard input + * Handles edit mode (Enter to edit, Esc to cancel, typing) + */ +export function useEditableField({ + value, + onChange, + focused, + type = 'text', + min, + max, + step: _step = 1, + onEditStart, + onEditEnd, +}: UseEditableFieldOptions): UseEditableFieldReturn { + const [isEditing, setIsEditing] = useState(false) + const [editValue, setEditValue] = useState('') + + const startEdit = useCallback(() => { + setIsEditing(true) + setEditValue(String(value)) + onEditStart?.() + }, [value, onEditStart]) + + const saveEdit = useCallback(() => { + if (type === 'number') { + const numValue = Number.parseInt(editValue, 10) + if (!Number.isNaN(numValue)) { + const clampedValue = Math.max(min ?? 0, Math.min(max ?? Number.MAX_SAFE_INTEGER, numValue)) + onChange(clampedValue as T) + } + } else { + onChange(editValue as T) + } + setIsEditing(false) + setEditValue('') + onEditEnd?.() + }, [editValue, type, min, max, onChange, onEditEnd]) + + const cancelEdit = useCallback(() => { + setIsEditing(false) + setEditValue('') + onEditEnd?.() + }, [onEditEnd]) + + // Reset editing state when focus is lost + useEffect(() => { + if (!focused && isEditing) { + cancelEdit() + } + }, [focused, isEditing, cancelEdit]) + + const handleInput = useCallback( + (input: string, key: { backspace?: boolean; delete?: boolean; return?: boolean; escape?: boolean }) => { + if (key.return) { + if (isEditing) { + saveEdit() + } else { + startEdit() + } + } else if (key.escape && isEditing) { + cancelEdit() + } else if (key.backspace || key.delete) { + setEditValue((prev) => prev.slice(0, -1)) + } else if (input && input.length === 1) { + if (type === 'number' && /^\d$/.test(input)) { + setEditValue((prev) => prev + input) + } else if (type === 'text') { + setEditValue((prev) => prev + input) + } + } + }, + [type, isEditing, saveEdit, cancelEdit, startEdit], + ) + + return { + isEditing, + editValue, + startEdit, + saveEdit, + cancelEdit, + handleInput, + } +} diff --git a/apps/cli/src/ui/hooks/useFormNavigation.ts b/apps/cli/src/ui/hooks/useFormNavigation.ts new file mode 100644 index 00000000000..125cd62cb9c --- /dev/null +++ b/apps/cli/src/ui/hooks/useFormNavigation.ts @@ -0,0 +1,67 @@ +import { useInput } from 'ink' +import { useCallback, useState } from 'react' + +interface UseFormNavigationOptions { + itemCount: number + onEscape?: () => void + enabled?: boolean + // Optional: block navigation when true (e.g., when editing a field) + blockNavigation?: boolean +} + +interface UseFormNavigationReturn { + focusedIndex: number + setFocusedIndex: (index: number) => void +} + +/** + * Hook for managing keyboard navigation in forms + * Handles up/down arrow navigation only - selection logic handled by parent + */ +export function useFormNavigation({ + itemCount, + onEscape, + enabled = true, + blockNavigation = false, +}: UseFormNavigationOptions): UseFormNavigationReturn { + const [focusedIndex, setFocusedIndex] = useState(0) + + const arrowUp = useCallback(() => { + setFocusedIndex((prev) => Math.max(0, prev - 1)) + }, []) + + const arrowDown = useCallback(() => { + setFocusedIndex((prev) => Math.min(itemCount - 1, prev + 1)) + }, [itemCount]) + + const handleEscape = useCallback(() => { + if (onEscape) { + onEscape() + } + }, [onEscape]) + + // Register keyboard handlers - only handles navigation arrows + useInput( + useCallback( + (_input: string, key: { upArrow?: boolean; downArrow?: boolean; escape?: boolean }) => { + if (!enabled || blockNavigation) { + return + } + + if (key.upArrow) { + arrowUp() + } else if (key.downArrow) { + arrowDown() + } else if (key.escape && onEscape) { + handleEscape() + } + }, + [enabled, blockNavigation, arrowUp, arrowDown, handleEscape, onEscape], + ), + ) + + return { + focusedIndex, + setFocusedIndex, + } +} diff --git a/apps/cli/src/ui/hooks/useReleases.ts b/apps/cli/src/ui/hooks/useReleases.ts new file mode 100644 index 00000000000..55eb85a0484 --- /dev/null +++ b/apps/cli/src/ui/hooks/useReleases.ts @@ -0,0 +1,106 @@ +import { type Release, ReleaseScanner } from '@universe/cli/src/lib/release-scanner' +import { useCallback, useEffect, useRef, useState } from 'react' + +interface UseReleasesResult { + releases: Release[] + loading: boolean + error: Error | null + getLatest: (platform: 'mobile' | 'extension') => Promise + getPrevious: (release: Release) => Promise + findRelease: (platform: 'mobile' | 'extension', version: string) => Promise + refresh: (platform?: 'mobile' | 'extension') => Promise +} + +export function useReleases(platform?: 'mobile' | 'extension'): UseReleasesResult { + const [releases, setReleases] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [scanner] = useState(() => new ReleaseScanner()) + const isScanningRef = useRef(false) + const lastScannedPlatformRef = useRef(undefined) + + const refresh = useCallback( + async (filterPlatform?: 'mobile' | 'extension') => { + // Guard: Prevent multiple simultaneous scans + if (isScanningRef.current) { + return + } + + const targetPlatform = filterPlatform || platform + const platformKey = targetPlatform || 'all' + + // Guard: Don't re-scan if we already scanned for this platform + if (lastScannedPlatformRef.current === platformKey) { + return + } + + try { + isScanningRef.current = true + setLoading(true) + setError(null) + const fetched = await scanner.scanReleases(targetPlatform) + setReleases(fetched) + lastScannedPlatformRef.current = platformKey + setLoading(false) + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))) + setLoading(false) + } finally { + isScanningRef.current = false + } + }, + [scanner, platform], + ) + + useEffect(() => { + // Only scan on initial mount or when platform actually changes + const platformKey = platform || 'all' + if (lastScannedPlatformRef.current !== platformKey) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises -- Intentionally fire-and-forget promise + refresh() + } + }, [platform, refresh]) + + const getLatest = useCallback( + async (targetPlatform: 'mobile' | 'extension'): Promise => { + try { + return await scanner.getLatestRelease(targetPlatform) + } catch (_err) { + return null + } + }, + [scanner], + ) + + const getPrevious = useCallback( + async (release: Release): Promise => { + try { + return await scanner.getPreviousRelease(release) + } catch (_err) { + return null + } + }, + [scanner], + ) + + const findRelease = useCallback( + async (targetPlatform: 'mobile' | 'extension', version: string): Promise => { + try { + return await scanner.findRelease(targetPlatform, version) + } catch (_err) { + return null + } + }, + [scanner], + ) + + return { + releases, + loading, + error, + getLatest, + getPrevious, + findRelease, + refresh, + } +} diff --git a/apps/cli/src/ui/hooks/useRepository.ts b/apps/cli/src/ui/hooks/useRepository.ts new file mode 100644 index 00000000000..ca3ed53b81a --- /dev/null +++ b/apps/cli/src/ui/hooks/useRepository.ts @@ -0,0 +1,53 @@ +import { detectRepository } from '@universe/cli/src/lib/team-resolver' +import { useEffect, useState } from 'react' + +interface Repository { + owner: string + name: string +} + +interface UseRepositoryResult { + repository: Repository | null + loading: boolean + error: Error | null +} + +export function useRepository(): UseRepositoryResult { + const [repository, setRepository] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + + async function detect(): Promise { + try { + setLoading(true) + setError(null) + const detected = await detectRepository() + if (!cancelled) { + if (detected && detected.owner && detected.name) { + setRepository({ owner: detected.owner, name: detected.name }) + } else { + setRepository(null) + } + setLoading(false) + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err : new Error(String(err))) + setLoading(false) + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-floating-promises -- Intentionally fire-and-forget promise + detect() + + return () => { + cancelled = true + } + }, []) + + return { repository, loading, error } +} diff --git a/apps/cli/src/ui/hooks/useTeams.ts b/apps/cli/src/ui/hooks/useTeams.ts new file mode 100644 index 00000000000..71c7e59bd82 --- /dev/null +++ b/apps/cli/src/ui/hooks/useTeams.ts @@ -0,0 +1,94 @@ +import { $ } from 'bun' +import { useCallback, useEffect, useRef, useState } from 'react' + +export interface GitHubTeam { + name: string + slug: string + description: string | null + membersCount?: number +} + +interface UseTeamsResult { + teams: GitHubTeam[] + loading: boolean + error: Error | null + refresh: () => Promise +} + +/** + * Hook to fetch teams from a GitHub organization + */ +export function useTeams(org: string | null): UseTeamsResult { + const [teams, setTeams] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const isFetchingRef = useRef(false) + const lastFetchedOrgRef = useRef(null) + + const refresh = useCallback(async () => { + if (!org) { + setLoading(false) + return + } + + // Guard: Prevent multiple simultaneous fetches + if (isFetchingRef.current) { + return + } + + // Guard: Don't re-fetch if we already fetched for this org + if (lastFetchedOrgRef.current === org) { + return + } + + try { + isFetchingRef.current = true + setLoading(true) + setError(null) + + // Fetch teams from GitHub API + const teamsResult = + await $`gh api /orgs/${org}/teams --jq '.[] | {name: .name, slug: .slug, description: .description}'`.text() + + const teamLines = teamsResult.trim().split('\n').filter(Boolean) + const parsedTeams: GitHubTeam[] = teamLines + .map((line: string) => { + try { + const parsed = JSON.parse(line) as GitHubTeam + return parsed + } catch { + return null + } + }) + .filter((team: GitHubTeam | null): team is GitHubTeam => team !== null) + + setTeams(parsedTeams) + lastFetchedOrgRef.current = org + setLoading(false) + } catch (err) { + setError( + err instanceof Error + ? err + : new Error(`Failed to fetch teams from org "${org}". Ensure gh CLI is authenticated.`), + ) + setLoading(false) + } finally { + isFetchingRef.current = false + } + }, [org]) + + useEffect(() => { + // Only fetch on initial mount or when org changes + if (org && lastFetchedOrgRef.current !== org) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises -- Intentionally fire-and-forget promise + refresh() + } + }, [org, refresh]) + + return { + teams, + loading, + error, + refresh, + } +} diff --git a/apps/cli/src/ui/hooks/useToggleGroup.ts b/apps/cli/src/ui/hooks/useToggleGroup.ts new file mode 100644 index 00000000000..5be9a703328 --- /dev/null +++ b/apps/cli/src/ui/hooks/useToggleGroup.ts @@ -0,0 +1,70 @@ +import { useCallback, useState } from 'react' + +interface UseToggleGroupOptions { + items: Array<{ key: T; label: string }> + initialSelected?: Set + minSelection?: number // Minimum number of items that must be selected +} + +interface UseToggleGroupReturn { + selected: Set + toggle: (key: T) => void + isSelected: (key: T) => boolean + selectAll: () => void + deselectAll: () => void +} + +/** + * Hook for managing a group of toggles/checkboxes + * Useful for multiple selection scenarios like output options + */ +export function useToggleGroup({ + items, + initialSelected = new Set(), + minSelection = 0, +}: UseToggleGroupOptions): UseToggleGroupReturn { + const [selected, setSelected] = useState>(initialSelected) + + const toggle = useCallback( + (key: T) => { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(key)) { + // Don't allow unchecking if it would violate minSelection + if (next.size > minSelection) { + next.delete(key) + } + } else { + next.add(key) + } + return next + }) + }, + [minSelection], + ) + + const isSelected = useCallback( + (key: T) => { + return selected.has(key) + }, + [selected], + ) + + const selectAll = useCallback(() => { + setSelected(new Set(items.map((item) => item.key))) + }, [items]) + + const deselectAll = useCallback(() => { + if (items.length >= minSelection) { + setSelected(new Set(items.slice(0, minSelection).map((item) => item.key))) + } + }, [items, minSelection]) + + return { + selected, + toggle, + isSelected, + selectAll, + deselectAll, + } +} diff --git a/apps/cli/src/ui/screens/BugBisectResultsScreen.tsx b/apps/cli/src/ui/screens/BugBisectResultsScreen.tsx new file mode 100644 index 00000000000..69b065a0e62 --- /dev/null +++ b/apps/cli/src/ui/screens/BugBisectResultsScreen.tsx @@ -0,0 +1,464 @@ +import { join } from 'node:path' +import { Select } from '@universe/cli/src/ui/components/Select' +import { TextInput } from '@universe/cli/src/ui/components/TextInput' +import { useAppState } from '@universe/cli/src/ui/hooks/useAppState' +import { useRepository } from '@universe/cli/src/ui/hooks/useRepository' +import { colors } from '@universe/cli/src/ui/utils/colors' +import { Box as InkBox, Text, useInput } from 'ink' +import { useCallback, useMemo, useState } from 'react' + +interface BugBisectResultsScreenProps { + results: { changelog: string; metadata: unknown } + onRestart: () => void +} + +interface SuspiciousCommit { + sha: string + confidence: number + reasoning: string + relatedPR?: number +} + +interface BugBisectResults { + suspiciousCommits?: SuspiciousCommit[] + summary?: string + totalCommitsAnalyzed?: number + releaseContext?: { + from: string + to: string + platform: string + } +} + +type ViewMode = 'menu' | 'save-file' | 'saved' + +function isValidBugBisectResults(value: unknown): value is BugBisectResults { + return ( + typeof value === 'object' && + value !== null && + 'suspiciousCommits' in value && + Array.isArray((value as BugBisectResults).suspiciousCommits) + ) +} + +function tryParseFromString(jsonString: string): BugBisectResults | null { + try { + const parsed = JSON.parse(jsonString) as BugBisectResults + if (isValidBugBisectResults(parsed)) { + return parsed + } + } catch { + // Not valid JSON or not valid BugBisectResults + } + return null +} + +function getConfidenceColor(confidence: number): string { + if (confidence >= 0.9) { + return 'red' + } + if (confidence >= 0.7) { + return '#ff8c00' + } // orange + if (confidence >= 0.5) { + return 'yellow' + } + return 'gray' +} + +function getConfidenceLabel(confidence: number): string { + if (confidence >= 0.9) { + return 'Very Likely' + } + if (confidence >= 0.7) { + return 'Likely' + } + if (confidence >= 0.5) { + return 'Possible' + } + return 'Unlikely' +} + +export function BugBisectResultsScreen({ results, onRestart }: BugBisectResultsScreenProps): JSX.Element { + const { state } = useAppState() + const { repository } = useRepository() + const [viewMode, setViewMode] = useState('menu') + const [filename, setFilename] = useState('bug-bisect-results.json') + const [filepath, setFilepath] = useState(process.cwd()) + const [savedPath, setSavedPath] = useState('') + const [focusedIndex, setFocusedIndex] = useState(0) + const [editingIndex, setEditingIndex] = useState(null) + const [editValue, setEditValue] = useState('') + const [saveError, setSaveError] = useState(null) + + // Parse results + const parsedResults = useMemo((): BugBisectResults | null => { + try { + // Try to parse from metadata if it's already parsed + if (results.metadata && typeof results.metadata === 'object') { + const metadata = results.metadata as Record + if (isValidBugBisectResults(metadata)) { + return metadata + } + } + + // Try to parse from changelog string (might be JSON) + if (typeof results.changelog === 'string') { + const parsed = tryParseFromString(results.changelog) + if (parsed) { + return parsed + } + } + + // Try to parse from metadata.analysis if it's a string + if (results.metadata && typeof results.metadata === 'object') { + const metadata = results.metadata as Record + const analysisString = metadata.analysis + if (typeof analysisString === 'string') { + const parsed = tryParseFromString(analysisString) + if (parsed) { + return parsed + } + } + } + + return null + } catch { + return null + } + }, [results]) + + const bugResults = useMemo((): BugBisectResults => { + if (parsedResults) { + return parsedResults + } + return { + suspiciousCommits: [], + summary: 'Failed to parse results', + totalCommitsAnalyzed: 0, + releaseContext: state.selectedRelease + ? { + from: state.comparisonRelease?.version || 'unknown', + to: state.selectedRelease.version, + platform: state.selectedRelease.platform, + } + : undefined, + } + }, [parsedResults, state.selectedRelease, state.comparisonRelease]) + + const githubBaseUrl = repository ? `https://github.com/${repository.owner}/${repository.name}` : '' + + const options = [ + { label: 'Save to File', value: 'save' }, + { label: 'Start Over', value: 'restart' }, + { label: 'Quit', value: 'quit' }, + ] + + const handleSelect = (option: { label: string; value: string }): void => { + if (option.value === 'quit') { + process.exit(0) + } else if (option.value === 'restart') { + onRestart() + } else if (option.value === 'save') { + setViewMode('save-file') + } + } + + const saveFile = useCallback(async () => { + try { + const fullPath = join(filepath, filename) + const content = JSON.stringify(bugResults, null, 2) + await Bun.write(fullPath, content) + setSavedPath(fullPath) + setViewMode('saved') + setSaveError(null) + } catch (error) { + setSaveError(error instanceof Error ? error.message : 'Failed to save file') + } + }, [filepath, filename, bugResults]) + + // Handle input for save-file mode (similar to ResultsScreen) + useInput( + useCallback( + (input, key) => { + if (viewMode !== 'save-file') { + return + } + + const isEditing = editingIndex !== null + + if (key.escape) { + if (isEditing) { + setEditingIndex(null) + setEditValue('') + } else { + setViewMode('menu') + setFocusedIndex(0) + setSaveError(null) + } + return + } + + if (!isEditing) { + if (key.upArrow) { + setFocusedIndex((prev) => Math.max(0, prev - 1)) + return + } + if (key.downArrow) { + setFocusedIndex((prev) => Math.min(2, prev + 1)) + return + } + if (key.return) { + if (focusedIndex === 0 || focusedIndex === 1) { + setEditingIndex(focusedIndex) + setEditValue(focusedIndex === 0 ? filename : filepath) + } else if (focusedIndex === 2) { + saveFile().catch(() => { + // Error already handled in saveFile + }) + } + return + } + } + + if (isEditing) { + if (key.return) { + if (editingIndex === 0) { + setFilename(editValue) + } else if (editingIndex === 1) { + setFilepath(editValue) + } + setEditingIndex(null) + setEditValue('') + return + } + + if (key.backspace || key.delete) { + setEditValue((prev) => prev.slice(0, -1)) + return + } + + if (input && input.length === 1) { + setEditValue((prev) => prev + input) + return + } + } + }, + [viewMode, focusedIndex, editingIndex, editValue, filename, filepath, saveFile], + ), + ) + + // Handle input for saved mode + useInput( + useCallback( + (_input, key) => { + if (viewMode === 'saved' && (key.return || key.escape)) { + setViewMode('menu') + setFocusedIndex(0) + } + }, + [viewMode], + ), + ) + + if (viewMode === 'save-file') { + return ( + + + + Save Results to File + + + + + + + + + + {focusedIndex === 2 ? '❯ ' : ' '} + Save File + + + + {saveError && ( + + Error: {saveError} + + )} + + + + Use ↑↓ to navigate, Enter to edit/save, Esc to {editingIndex !== null ? 'cancel' : 'go back'} + + + + + ) + } + + if (viewMode === 'saved') { + return ( + + + + ✓ File Saved Successfully + + + + + + Saved to:{' '} + + {savedPath} + + + + + Press Enter or Esc to return to menu + + + + ) + } + + const suspiciousCommits = bugResults.suspiciousCommits || [] + + return ( + + + + ✓ Bug Analysis Complete + + + + + {/* Bug Description */} + {state.bugDescription && ( + + Bug Description + {state.bugDescription} + + )} + + {/* Release Context */} + {bugResults.releaseContext && ( + + + Platform: {bugResults.releaseContext.platform} + + + Release: {bugResults.releaseContext.from} → {bugResults.releaseContext.to} + + {bugResults.totalCommitsAnalyzed !== undefined && ( + + Commits Analyzed: {bugResults.totalCommitsAnalyzed} + + )} + + )} + + {/* Summary */} + {bugResults.summary && ( + + {bugResults.summary} + + )} + + {/* Suspicious Commits */} + {suspiciousCommits.length > 0 ? ( + + + + Suspicious Commits ({suspiciousCommits.length}) + + + + {suspiciousCommits.slice(0, 20).map((commit, index) => { + const confidenceColor = getConfidenceColor(commit.confidence) + const confidenceLabel = getConfidenceLabel(commit.confidence) + const shortSha = commit.sha.slice(0, 7) + const commitUrl = githubBaseUrl ? `${githubBaseUrl}/commit/${commit.sha}` : '' + const prUrl = commit.relatedPR && githubBaseUrl ? `${githubBaseUrl}/pull/${commit.relatedPR}` : '' + + return ( + + + + #{index + 1}. {shortSha} + + + + {confidenceLabel} ({Math.round(commit.confidence * 100)}%) + + + + + + {commitUrl ? ( + + {commitUrl} + + ) : ( + SHA: {commit.sha} + )} + + {commit.relatedPR && ( + + PR #{commit.relatedPR} + {prUrl && ( + + {' '} + - {prUrl} + + )} + + )} + + + + {commit.reasoning} + + + ) + })} + + {suspiciousCommits.length > 20 && ( + + ... and {suspiciousCommits.length - 20} more commits + + )} + + ) : ( + + ⚠ No suspicious commits found + + )} + + + + What would you like to do next? + + + + { + if (item.value === 'back') { + setMode('browse') + } else { + setPlatformFilter(item.value as 'mobile' | 'extension' | 'all') + setMode('browse') + } + }} + /> + + + ) + } + + // Browse mode - show releases + if (mode === 'browse') { + // Create options with navigation and filter controls at the top + const browseOptions = [ + { label: '← Back to Quick Actions', value: 'back' }, + { label: '🔍 Filter by Platform', value: 'filter' }, + ...filteredReleases.map((release: Release, index: number) => ({ + label: `${release.platform}/${release.version} (${formatBranch(release.branch)})`, + value: String(index + 2), // Offset by 2 for back and filter options + release, + })), + ] + + return ( + + + + Select Release + + + + {loading && ( + + Loading releases... + + )} + {error && ( + + Error: {error.message} + + )} + + {!loading && !error && ( + <> + + Showing {filteredReleases.length} release{filteredReleases.length !== 1 ? 's' : ''}{' '} + {platformFilter !== 'all' ? `(${platformFilter})` : ''} + + { + if (item.value === 'back') { + setMode('quick') + } else if (item.value === 'filter') { + setMode('filter-platform') + } else if (item.release) { + const release = item.release + const index = filteredReleases.findIndex( + (r: Release) => r.platform === release.platform && r.version === release.version, + ) + if (index >= 0) { + handleBrowseSelect(index) + } + } + }} + /> + + )} + + + ) + } + + return ( + + + + Release Selection + + + + + Choose a quick action or browse releases: + + + + + ) +} diff --git a/apps/cli/src/ui/screens/TeamDetailsScreen.tsx b/apps/cli/src/ui/screens/TeamDetailsScreen.tsx new file mode 100644 index 00000000000..55086f74b85 --- /dev/null +++ b/apps/cli/src/ui/screens/TeamDetailsScreen.tsx @@ -0,0 +1,152 @@ +import { fetchTeamMembers, type TeamMember } from '@universe/cli/src/lib/team-members' +import { resolveTeam } from '@universe/cli/src/lib/team-resolver' +import { Select } from '@universe/cli/src/ui/components/Select' +import { StatusBadge } from '@universe/cli/src/ui/components/StatusBadge' +import { type TeamFilter, useAppState } from '@universe/cli/src/ui/hooks/useAppState' +import type { GitHubTeam } from '@universe/cli/src/ui/hooks/useTeams' +import { colors } from '@universe/cli/src/ui/utils/colors' +import { Box, Text } from 'ink' +import Spinner from 'ink-spinner' +import { useEffect, useState } from 'react' + +interface TeamDetailsScreenProps { + team: GitHubTeam + org: string + onSelect: (teamFilter: TeamFilter) => void + onBack: () => void +} + +export function TeamDetailsScreen({ team, org, onSelect, onBack }: TeamDetailsScreenProps): JSX.Element { + const { dispatch } = useAppState() + const [members, setMembers] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + + const loadMembers = async (): Promise => { + try { + setLoading(true) + setError(null) + + // Fetch members for display + const fetchedMembers = await fetchTeamMembers(org, team.slug) + + if (!cancelled) { + setMembers(fetchedMembers) + + // Also resolve to emails/usernames and cache for later use + const teamSlug = `@${org}/${team.slug}` + try { + const { emails, usernames } = await resolveTeam(teamSlug) + dispatch({ + type: 'CACHE_TEAM_MEMBERS', + teamSlug, + members: { emails, usernames }, + }) + } catch { + // If resolveTeam fails, continue with display but don't cache + // The user can still see members, just won't be cached + } + + setLoading(false) + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err : new Error('Failed to fetch team members')) + setLoading(false) + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-floating-promises -- Intentionally fire-and-forget promise + loadMembers() + + return () => { + cancelled = true + } + }, [org, team.slug, dispatch]) + + const handleSelectTeam = (): void => { + const teamFilter: TeamFilter = { + teams: [`@${org}/${team.slug}`], + } + dispatch({ type: 'SET_TEAM_FILTER', filter: teamFilter }) + onSelect(teamFilter) + } + + return ( + + + + Team Details + + + + + + @{org}/{team.slug} + + {team.name} + {team.description && {team.description}} + + + + + Members + + + {loading && ( + + + + + Loading members... + + )} + + {error && ( + + {error.message} + + )} + + {!loading && !error && members.length === 0 && ( + + No members found + + )} + + {!loading && !error && members.length > 0 && ( + + + {members.length} member{members.length !== 1 ? 's' : ''} + + + {members.map((member) => ( + + • {member.name ? `${member.name} (@${member.login})` : `@${member.login}`} + + ))} + + + )} + + + { + if (item.value === 'confirm') { + handleManualConfirm() + } else { + setMode('quick') + setManualStep('teams') + } + }} + /> + + + + ) + } + } + + // Team details mode + if (mode === 'details' && selectedTeamForDetails && org) { + return ( + { + setMode('browse') + setSelectedTeamForDetails(null) + }} + /> + ) + } + + // Browse mode + if (mode === 'browse') { + const browseOptions = [ + { label: '← Back to Quick Actions', value: 'back' }, + ...teams.map((team: GitHubTeam) => ({ + label: `@${org}/${team.slug} - ${team.name}${team.description ? ` (${team.description})` : ''}`, + value: team.slug, + team, + })), + ] + + return ( + + + + Select Team + + + + {loading && ( + + Loading teams from {org}... + + )} + {error && ( + + Error: {error.message} + + )} + + {!loading && !error && ( + <> + + Found {teams.length} team{teams.length !== 1 ? 's' : ''} in {org} + + {focusedTeam && Press Tab to view team members} + { + if (item.value === 'back') { + setMode('quick') + setFocusedTeam(null) + } else if (item.team) { + handleBrowseSelect(item.team) + } + }} + onFocusChange={(item: { value: string; team?: GitHubTeam } | null) => { + setFocusedTeam(item?.team ?? null) + }} + /> + + )} + + + ) + } + + // Default: Quick actions mode + return ( + + + + Team Filter Selection + + + + + Choose how to filter contributors: + + + + + ) +} diff --git a/apps/cli/src/ui/services/orchestrator-service.ts b/apps/cli/src/ui/services/orchestrator-service.ts new file mode 100644 index 00000000000..fe8f3777de2 --- /dev/null +++ b/apps/cli/src/ui/services/orchestrator-service.ts @@ -0,0 +1,68 @@ +import type { OrchestratorConfig } from '@universe/cli/src/core/orchestrator' +import { Orchestrator } from '@universe/cli/src/core/orchestrator' +import { createVercelAIProvider } from '@universe/cli/src/lib/ai-provider-vercel' +import { SqliteCacheProvider } from '@universe/cli/src/lib/cache-provider-sqlite' +import { type ProgressEvent, ProgressLogger, type ProgressStage } from '@universe/cli/src/lib/logger' + +export type { ProgressStage, ProgressEvent } + +export type ProgressCallback = (event: ProgressEvent) => void + +export class OrchestratorService { + private orchestrator: Orchestrator | null = null + private progressCallback: ProgressCallback | null = null + + async execute(config: OrchestratorConfig, onProgress?: ProgressCallback): Promise> { + this.progressCallback = onProgress || null + + // Create cache provider (unless bypassing cache) + const bypassCache = config.bypassCache || false + const cacheProvider = bypassCache ? undefined : new SqliteCacheProvider() + + // Create AI provider + const apiKey = process.env.ANTHROPIC_API_KEY + if (!apiKey) { + throw new Error('ANTHROPIC_API_KEY environment variable is required') + } + const aiProvider = createVercelAIProvider(apiKey) + + // Ensure repoPath is set in collect options + const configWithRepoPath: OrchestratorConfig = { + ...config, + collect: { + ...config.collect, + repoPath: config.collect.repoPath || process.cwd(), + }, + } + + // Create progress logger that emits events for interactive UI mode + const logger = new ProgressLogger((event: ProgressEvent) => { + this.emitProgress(event) + }, config.verbose || false) + + // Create orchestrator with progress logger + this.orchestrator = new Orchestrator({ + config: configWithRepoPath, + aiProvider, + cacheProvider, + logger, + }) + + try { + // Execute and capture the analysis results + const results = await this.orchestrator.execute() + return results + } finally { + // Close cache connection if used + if (cacheProvider) { + cacheProvider.close() + } + } + } + + private emitProgress(event: ProgressEvent): void { + if (this.progressCallback) { + this.progressCallback(event) + } + } +} diff --git a/apps/cli/src/ui/utils/colors.ts b/apps/cli/src/ui/utils/colors.ts new file mode 100644 index 00000000000..624a198f6c4 --- /dev/null +++ b/apps/cli/src/ui/utils/colors.ts @@ -0,0 +1,13 @@ +/** + * Theme color constants for Ink UI + * Using Uniswap pink color palette + */ +export const colors = { + primary: '#FC74FE', // Uniswap pink for interactive elements + success: '#00FF00', // Green for completions + warning: '#FFFF00', // Yellow for important info + error: '#FF0000', // Red for errors + muted: '#888888', // Gray for secondary text +} as const + +export type ColorName = keyof typeof colors diff --git a/apps/cli/src/ui/utils/format.ts b/apps/cli/src/ui/utils/format.ts new file mode 100644 index 00000000000..689d619a803 --- /dev/null +++ b/apps/cli/src/ui/utils/format.ts @@ -0,0 +1,18 @@ +/** + * Text formatting utilities for UI components + */ + +export function truncate(text: string, maxLength: number): string { + if (text.length <= maxLength) { + return text + } + return `${text.slice(0, maxLength - 3)}...` +} + +export function formatVersion(version: string): string { + return version +} + +export function formatBranch(branch: string): string { + return branch.replace('origin/releases/', '') +} diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json new file mode 100644 index 00000000000..748b6c093fa --- /dev/null +++ b/apps/cli/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../config/tsconfig/app.json", + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.json", "src/global.d.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.spec.tsx", "src/**/*.test.ts", "src/**/*.test.tsx"], + "compilerOptions": { + "noEmit": false, + "module": "esnext", + "moduleResolution": "bundler" + }, + "references": [] +} diff --git a/apps/cli/tsconfig.lint.json b/apps/cli/tsconfig.lint.json new file mode 100644 index 00000000000..79659c26038 --- /dev/null +++ b/apps/cli/tsconfig.lint.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "preserveSymlinks": true + }, + "include": ["**/*.ts", "**/*.tsx", "**/*.json"], + "exclude": ["node_modules"] +} diff --git a/apps/extension/e2e/sandbox-test/index.html b/apps/extension/e2e/sandbox-test/index.html index 1ec9b415aad..4f0b4315f4f 100644 --- a/apps/extension/e2e/sandbox-test/index.html +++ b/apps/extension/e2e/sandbox-test/index.html @@ -111,11 +111,10 @@

3. Sandboxed (allow-scripts + allow-same-origin)

const el = document.getElementById('result-' + frameId); el.className = 'result ' + (allPass ? 'pass' : 'fail'); - el.innerHTML = ` - ${allPass ? 'PASS' : 'FAIL'} — - origin: ${origin} (${originPass ? 'ok' : 'WRONG'}), - Uniswap provider: ${hasUniswapProvider ? 'present' : 'absent'} (${providerPass ? 'ok' : 'WRONG'}) - `; + el.textContent = + `${allPass ? 'PASS' : 'FAIL'} — ` + + `origin: ${origin} (${originPass ? 'ok' : 'WRONG'}), ` + + `Uniswap provider: ${hasUniswapProvider ? 'present' : 'absent'} (${providerPass ? 'ok' : 'WRONG'})`; }); // Timeout fallback diff --git a/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignSendCallsTransaction.ts b/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignSendCallsTransaction.ts index 3c38ed93624..000f77c6379 100644 --- a/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignSendCallsTransaction.ts +++ b/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignSendCallsTransaction.ts @@ -58,6 +58,7 @@ export function usePrepareAndSignSendCallsTransaction({ }) : [], smartContractDelegationAddress: UNISWAP_DELEGATION_ADDRESS, + // @ts-expect-error - TODO: no longer available in API types, verify if still needed walletAddress: account.address, }, }) diff --git a/apps/extension/src/app/features/home/PortfolioActionButtons.tsx b/apps/extension/src/app/features/home/PortfolioActionButtons.tsx index 562209828eb..cd722938849 100644 --- a/apps/extension/src/app/features/home/PortfolioActionButtons.tsx +++ b/apps/extension/src/app/features/home/PortfolioActionButtons.tsx @@ -1,4 +1,5 @@ import { SharedEventName } from '@uniswap/analytics-events' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { cloneElement, memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useInterfaceBuyNavigator } from 'src/app/features/for/utils' diff --git a/apps/extension/src/app/features/home/introCards/HomeIntroCardStack.tsx b/apps/extension/src/app/features/home/introCards/HomeIntroCardStack.tsx index 9d969166c42..1f911c251cc 100644 --- a/apps/extension/src/app/features/home/introCards/HomeIntroCardStack.tsx +++ b/apps/extension/src/app/features/home/introCards/HomeIntroCardStack.tsx @@ -1,8 +1,10 @@ -import { useCallback } from 'react' +import { useCallback, useState } from 'react' import { AppRoutes, SettingsRoutes, UnitagClaimRoutes } from 'src/app/navigation/constants' import { focusOrCreateUnitagTab, useExtensionNavigation } from 'src/app/navigation/utils' import { Flex } from 'ui/src' +import { MonadAnnouncementModal } from 'uniswap/src/components/notifications/MonadAnnouncementModal' import { AccountType } from 'uniswap/src/features/accounts/types' +import { useEvent } from 'utilities/src/react/hooks' import { IntroCardStack } from 'wallet/src/components/introCards/IntroCardStack' import { useSharedIntroCards } from 'wallet/src/components/introCards/useSharedIntroCards' import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' @@ -11,6 +13,7 @@ export function HomeIntroCardStack(): JSX.Element | null { const activeAccount = useActiveAccountWithThrow() const isSignerAccount = activeAccount.type === AccountType.SignerMnemonic const { navigateTo } = useExtensionNavigation() + const [isMonadModalOpen, setIsMonadModalOpen] = useState(false) const navigateToUnitagClaim = useCallback(async () => { await focusOrCreateUnitagTab(activeAccount.address, UnitagClaimRoutes.ClaimIntro) @@ -20,10 +23,16 @@ export function HomeIntroCardStack(): JSX.Element | null { navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.BackupRecoveryPhrase}`) }, [navigateTo]) + const handleMonadExplorePress = useEvent(() => { + window.open('https://app.uniswap.org/explore/tokens/monad', '_blank') + setIsMonadModalOpen(false) + }) + const { cards } = useSharedIntroCards({ navigateToUnitagClaim, navigateToUnitagIntro: navigateToUnitagClaim, // No need to differentiate for extension navigateToBackupFlow, + onMonadAnnouncementPress: () => setIsMonadModalOpen(true), }) if (!cards.length || !isSignerAccount) { @@ -31,8 +40,17 @@ export function HomeIntroCardStack(): JSX.Element | null { } return ( - - - + <> + + + + {isMonadModalOpen && ( + setIsMonadModalOpen(false)} + onExplorePress={handleMonadExplorePress} + /> + )} + ) } diff --git a/apps/mobile/Gemfile b/apps/mobile/Gemfile index e1d5aa1dd5b..9e2644eb834 100644 --- a/apps/mobile/Gemfile +++ b/apps/mobile/Gemfile @@ -1,23 +1,11 @@ source "https://rubygems.org" -gem 'fastlane', '2.228.0' -gem 'cocoapods', '1.16.2' -gem 'activesupport', '7.1.2' +gem 'fastlane', '2.216.0' +# Exclude problematic versions of cocoapods and activesupport that causes build failures. +gem 'cocoapods', '1.15.1' +gem 'activesupport', '7.2.3.1' gem 'xcodeproj', '1.27.0' gem 'concurrent-ruby', '1.3.4' -# Ruby 3.4.0 removed these from the standard library. -# See: https://github.com/fastlane/fastlane/issues/29183 -# See: https://www.ruby-lang.org/en/news/2024/12/25/ruby-3-4-0-released/ -gem 'abbrev' -gem 'base64' -gem 'bigdecimal' -gem 'benchmark' -gem 'drb' -gem 'logger' -gem 'mutex_m' -gem 'nkf' -gem 'ostruct' - plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') eval_gemfile(plugins_path) if File.exist?(plugins_path) diff --git a/apps/mobile/Gemfile.lock b/apps/mobile/Gemfile.lock index 315476a5e86..40726c625b9 100644 --- a/apps/mobile/Gemfile.lock +++ b/apps/mobile/Gemfile.lock @@ -1,29 +1,29 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.6) - rexml - abbrev (0.1.2) - activesupport (7.1.2) + CFPropertyList (3.0.9) + activesupport (7.2.3.1) base64 + benchmark (>= 0.3) bigdecimal - concurrent-ruby (~> 1.0, >= 1.0.2) + concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) - minitest (>= 5.1) - mutex_m - tzinfo (~> 2.0) - addressable (2.8.6) - public_suffix (>= 2.0.2, < 6.0) + logger (>= 1.4.2) + minitest (>= 5.1, < 6) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) algoliasearch (1.27.5) httpclient (~> 2.8, >= 2.8.3) json (>= 1.5.1) artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1162.0) - aws-sdk-core (3.232.0) + aws-partitions (1.1240.0) + aws-sdk-core (3.245.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -31,24 +31,24 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.112.0) - aws-sdk-core (~> 3, >= 3.231.0) + aws-sdk-kms (1.123.0) + aws-sdk-core (~> 3, >= 3.244.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.199.0) - aws-sdk-core (~> 3, >= 3.231.0) + aws-sdk-s3 (1.219.0) + aws-sdk-core (~> 3, >= 3.244.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) - base64 (0.2.0) - benchmark (0.4.1) - bigdecimal (3.1.9) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.1) claide (1.1.0) - cocoapods (1.16.2) + cocoapods (1.15.1) addressable (~> 2.8) claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.16.2) + cocoapods-core (= 1.15.1) cocoapods-deintegrate (>= 1.0.3, < 2.0) cocoapods-downloader (>= 2.1, < 3.0) cocoapods-plugins (>= 1.0.0, < 2.0) @@ -62,8 +62,8 @@ GEM molinillo (~> 0.8.0) nap (~> 1.0) ruby-macho (>= 2.3.0, < 3.0) - xcodeproj (>= 1.27.0, < 2.0) - cocoapods-core (1.16.2) + xcodeproj (>= 1.23.0, < 2.0) + cocoapods-core (1.15.1) activesupport (>= 5.0, < 8) addressable (~> 2.8) algoliasearch (~> 1.0) @@ -87,19 +87,20 @@ GEM commander (4.6.0) highline (~> 2.0.0) concurrent-ruby (1.3.4) - connection_pool (2.5.0) + connection_pool (2.5.5) declarative (0.0.20) digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) domain_name (0.6.20240107) dotenv (2.8.1) - drb (2.2.1) + drb (2.2.3) emoji_regex (3.2.3) escape (0.0.4) - ethon (0.15.0) + ethon (0.18.0) ffi (>= 1.15.0) + logger excon (0.112.0) - faraday (1.10.4) + faraday (1.10.5) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -111,31 +112,31 @@ GEM faraday-rack (~> 1.0) faraday-retry (~> 1.0) ruby2_keywords (>= 0.0.4) - faraday-cookie_jar (0.0.7) + faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) - http-cookie (~> 1.0.0) + http-cookie (>= 1.0.0) faraday-em_http (1.0.0) faraday-em_synchrony (1.0.1) faraday-excon (1.1.0) faraday-httpclient (1.0.1) - faraday-multipart (1.1.1) + faraday-multipart (1.2.0) multipart-post (~> 2.0) faraday-net_http (1.0.2) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) - faraday-retry (1.0.3) + faraday-retry (1.0.4) faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.4.1) - fastlane (2.228.0) + fastlane (2.216.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) aws-sdk-s3 (~> 1.0) babosa (>= 1.0.3, < 2.0.0) bundler (>= 1.12.0, < 3.0.0) - colored (~> 1.2) + colored commander (~> 4.6) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) @@ -144,11 +145,9 @@ GEM faraday-cookie_jar (~> 0.0.6) faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) - fastlane-sirp (>= 1.0.0) gh_inspector (>= 1.1.2, < 2.0.0) google-apis-androidpublisher_v3 (~> 0.3) google-apis-playcustomapp_v1 (~> 0.1) - google-cloud-env (>= 1.6.0, < 2.0.0) google-cloud-storage (~> 1.31) highline (~> 2.0) http-cookie (~> 1.0.5) @@ -157,10 +156,10 @@ GEM mini_magick (>= 4.9.4, < 5.0.0) multipart-post (>= 2.0.0, < 3.0.0) naturally (~> 2.2) - optparse (>= 0.1.1, < 1.0.0) + optparse (~> 0.1.1) plist (>= 3.1.0, < 4.0.0) rubyzip (>= 2.0.0, < 3.0.0) - security (= 0.1.5) + security (= 0.1.3) simctl (~> 1.6.3) terminal-notifier (>= 2.0.0, < 3.0.0) terminal-table (~> 3) @@ -168,92 +167,93 @@ GEM tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) xcodeproj (>= 1.13.0, < 2.0.0) - xcpretty (~> 0.4.1) - xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) - fastlane-plugin-get_version_name (0.2.2) - fastlane-plugin-versioning_android (0.1.1) - fastlane-sirp (1.0.0) - sysrandom (~> 1.0) - ffi (1.17.2-arm64-darwin) + xcpretty (~> 0.3.0) + xcpretty-travis-formatter (>= 0.0.3) + ffi (1.17.4) fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.54.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-core (0.11.3) + google-apis-androidpublisher_v3 (0.98.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (0.18.0) addressable (~> 2.5, >= 2.5.1) - googleauth (>= 0.16.2, < 2.a) - httpclient (>= 2.8.1, < 3.a) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) mini_mime (~> 1.0) + mutex_m representable (~> 3.0) retriable (>= 2.0, < 4.a) - rexml - google-apis-iamcredentials_v1 (0.17.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-playcustomapp_v1 (0.13.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-storage_v1 (0.31.0) - google-apis-core (>= 0.11.0, < 2.a) + google-apis-iamcredentials_v1 (0.26.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.17.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.61.0) + google-apis-core (>= 0.15.0, < 2.a) google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) - google-cloud-env (1.6.0) - faraday (>= 0.17.3, < 3.0) - google-cloud-errors (1.5.0) - google-cloud-storage (1.47.0) + google-cloud-env (2.3.1) + base64 (~> 0.2) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.6.0) + google-cloud-storage (1.59.0) addressable (~> 2.8) digest-crc (~> 0.4) - google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.31.0) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) google-cloud-core (~> 1.6) - googleauth (>= 0.16.2, < 2.a) + googleauth (~> 1.9) mini_mime (~> 1.0) - googleauth (1.8.1) - faraday (>= 0.17.3, < 3.a) - jwt (>= 1.4, < 3.0) + google-logging-utils (0.2.0) + googleauth (1.16.2) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) highline (2.0.3) http-cookie (1.0.8) domain_name (~> 0.5) - httpclient (2.8.3) - i18n (1.14.7) + httpclient (2.9.0) + mutex_m + i18n (1.14.8) concurrent-ruby (~> 1.0) jmespath (1.6.2) - json (2.7.1) + json (2.19.3) jwt (2.10.2) base64 logger (1.7.0) mini_magick (4.13.2) mini_mime (1.1.5) - minitest (5.25.4) + minitest (5.27.0) molinillo (0.8.0) - multi_json (1.17.0) + multi_json (1.19.1) multipart-post (2.4.1) mutex_m (0.3.0) nanaimo (0.4.0) nap (1.1.0) naturally (2.3.0) netrc (0.11.0) - nkf (0.2.0) - optparse (0.6.0) + optparse (0.1.1) os (1.1.4) - ostruct (0.6.3) plist (3.7.2) public_suffix (4.0.7) - rake (13.3.0) + rake (13.4.2) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) - retriable (3.1.2) - rexml (3.4.1) - rouge (3.28.0) + retriable (3.4.1) + rexml (3.4.4) + rouge (2.0.7) ruby-macho (2.5.1) ruby2_keywords (0.0.5) rubyzip (2.4.1) - security (0.1.5) + securerandom (0.4.1) + security (0.1.3) signet (0.21.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) @@ -262,7 +262,6 @@ GEM simctl (1.6.10) CFPropertyList naturally - sysrandom (1.0.5) terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) @@ -271,8 +270,8 @@ GEM tty-screen (0.8.2) tty-spinner (0.9.3) tty-cursor (~> 0.7) - typhoeus (1.5.0) - ethon (>= 0.9.0, < 0.16.0) + typhoeus (1.6.0) + ethon (>= 0.18.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) uber (0.1.0) @@ -285,33 +284,20 @@ GEM colored2 (~> 3.1) nanaimo (~> 0.4.0) rexml (>= 3.3.6, < 4.0) - xcpretty (0.4.1) - rouge (~> 3.28.0) + xcpretty (0.3.0) + rouge (~> 2.0.7) xcpretty-travis-formatter (1.0.1) xcpretty (~> 0.2, >= 0.0.7) PLATFORMS - arm64-darwin-23 - arm64-darwin-24 - arm64-darwin-25 + ruby DEPENDENCIES - abbrev - activesupport (= 7.1.2) - base64 - benchmark - bigdecimal - cocoapods (= 1.16.2) + activesupport (= 7.2.3.1) + cocoapods (= 1.15.1) concurrent-ruby (= 1.3.4) - drb - fastlane (= 2.228.0) - fastlane-plugin-get_version_name - fastlane-plugin-versioning_android - logger - mutex_m - nkf - ostruct + fastlane (= 2.216.0) xcodeproj (= 1.27.0) BUNDLED WITH - 2.4.10 + 2.3.27 diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 7a2ad588dbd..16a8127d283 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -146,7 +146,7 @@ "expo-web-browser": "14.2.0", "fuse.js": "6.5.3", "i18next": "23.10.0", - "lodash": "4.17.23", + "lodash": "4.18.1", "react": "19.0.3", "react-freeze": "1.0.3", "react-i18next": "14.1.0", diff --git a/apps/mobile/src/features/deepLinking/deepLinkUtils.ts b/apps/mobile/src/features/deepLinking/deepLinkUtils.ts index 39051f1897a..42c9103fa01 100644 --- a/apps/mobile/src/features/deepLinking/deepLinkUtils.ts +++ b/apps/mobile/src/features/deepLinking/deepLinkUtils.ts @@ -1,3 +1,4 @@ +import { DeepLinkUrlAllowlist } from '@universe/gating' import { getScantasticQueryParams } from 'src/components/Requests/ScanSheet/util' import { UNISWAP_URL_SCHEME_UWU_LINK } from 'src/components/Requests/Uwulink/utils' import { diff --git a/apps/web/.depcheckrc b/apps/web/.depcheckrc index d43c039c348..021ee269474 100644 --- a/apps/web/.depcheckrc +++ b/apps/web/.depcheckrc @@ -22,9 +22,6 @@ ignores: [ '@vitest/coverage-v8', 'expo-crypto', '@datadog/datadog-ci', - 'typescript', - '@typescript/native-preview', - 'playwright', # Dependencies that depcheck thinks are missing but are actually present or never used 'stories', ## package.json scripts @@ -69,8 +66,36 @@ ignores: [ 'utilities', 'ui', ## Top level local file paths - '~', + 'abis', + 'analytics', + 'appGraphql', + 'assets', + 'components', + 'connection', + 'constants', + 'dev', + 'featureFlags', + 'features', + 'hooks', + 'lib', + 'locales', + 'nft', + 'pages', + 'polyfills', + 'rpc', + 'shared-cloud', 'functions', + 'src', + 'state', + 'test-utils', + 'theme', + 'tracing', + 'types', + 'utils', + 'i18n', + 'tamagui.config', + 'setupRive', + 'sideEffects', 'global.css', # needed for ci 'dd-trace', diff --git a/apps/web/cypress/support/commands.ts b/apps/web/cypress/support/commands.ts new file mode 100644 index 00000000000..f35f18e9b4b --- /dev/null +++ b/apps/web/cypress/support/commands.ts @@ -0,0 +1,168 @@ +import 'cypress-hardhat/lib/browser' + +import { Eip1193 } from 'cypress-hardhat/lib/browser/eip1193' +import { FeatureFlagClient, FeatureFlags, getFeatureFlagName } from 'uniswap/src/features/gating/flags' +import { ALLOW_ANALYTICS_ATOM_KEY } from 'utilities/src/telemetry/analytics/constants' +import { UserState, initialState } from '../../src/state/user/reducer' +import { setInitialUserState } from '../utils/user-state' + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface ApplicationWindow { + ethereum: Eip1193 + } + interface Chainable { + /** + * Wait for a specific event to be sent to amplitude. If the event is found, the subject will be the event. + * + * @param {string} eventName - The type of the event to search for e.g. SwapEventName.SWAP_TRANSACTION_COMPLETED + * @param {number} timeout - The maximum amount of time (in ms) to wait for the event. + * @returns {Chainable} + */ + waitForAmplitudeEvent(eventName: string, requiredProperties?: string[]): Chainable + /** + * Intercepts a specific graphql operation and responds with the given fixture. + * @param {string} operationName - The name of the graphql operation to intercept. + * @param {string} fixturePath - The path to the fixture to respond with. + */ + interceptGraphqlOperation(operationName: string, fixturePath: string): Chainable + /** + * Intercepts a quote request and responds with the given fixture. + * @param {string} fixturePath - The path to the fixture to respond with. + */ + interceptQuoteRequest(fixturePath: string): Chainable + } + interface Cypress { + eagerlyConnect?: boolean + } + interface VisitOptions { + featureFlags?: Array<{ flag: FeatureFlags; value: boolean }> + /** + * Initial user state. + * @default {@type import('../utils/user-state').CONNECTED_WALLET_USER_STATE} + */ + userState?: Partial + /** + * If false, prevents the app from eagerly connecting to the injected provider. + * @default true + */ + eagerlyConnect?: false + } + } +} + +export function registerCommands() { + // sets up the injected provider to be a mock ethereum provider with the given mnemonic/index + // eslint-disable-next-line no-undef + Cypress.Commands.overwrite( + 'visit', + (original, url: string | Partial, options?: Partial) => { + if (typeof url !== 'string') { + throw new Error('Invalid arguments. The first argument to cy.visit must be the path.') + } + + // Parse overrides + const flagsOn: FeatureFlags[] = [] + const flagsOff: FeatureFlags[] = [] + options?.featureFlags?.forEach((f) => { + if (f.value) { + flagsOn.push(f.flag) + } else { + flagsOff.push(f.flag) + } + }) + + // Format into URL parameters + const overrideParams = new URLSearchParams() + if (flagsOn.length > 0) { + overrideParams.append( + 'featureFlagOverride', + flagsOn.map((flag) => getFeatureFlagName(flag, FeatureFlagClient.Web)).join(','), + ) + } + if (flagsOff.length > 0) { + overrideParams.append( + 'featureFlagOverrideOff', + flagsOn.map((flag) => getFeatureFlagName(flag, FeatureFlagClient.Web)).join(','), + ) + } + + return cy.provider().then((provider) => + original({ + ...options, + url: + [...overrideParams.entries()].length === 0 + ? url + : url.includes('?') + ? `${url}&${overrideParams.toString()}` + : `${url}?${overrideParams.toString()}`, + onBeforeLoad(win) { + options?.onBeforeLoad?.(win) + + setInitialUserState(win, { + ...initialState, + ...(options?.userState ?? {}), + }) + + win.ethereum = provider + win.Cypress.eagerlyConnect = options?.eagerlyConnect ?? true + win.localStorage.setItem(ALLOW_ANALYTICS_ATOM_KEY, 'true') + win.localStorage.setItem('showUniswapExtensionLaunchAtom', 'false') + }, + }), + ) + }, + ) + + Cypress.Commands.add('waitForAmplitudeEvent', (eventName, requiredProperties) => { + function findAndDiscardEventsUpToTarget() { + const events = Cypress.env('amplitudeEventCache') + const targetEventIndex = events.findIndex((event) => { + if (event.event_type !== eventName) { + return false + } + if (requiredProperties) { + return requiredProperties.every((prop) => event.event_properties[prop]) + } + return true + }) + + if (targetEventIndex !== -1) { + const event = events[targetEventIndex] + Cypress.env('amplitudeEventCache', events.slice(targetEventIndex + 1)) + return cy.wrap(event) + } else { + // If not found, retry after waiting for more events to be sent. + return cy.wait('@amplitude').then(findAndDiscardEventsUpToTarget) + } + } + return findAndDiscardEventsUpToTarget() + }) + + Cypress.env('graphqlInterceptions', new Map()) + + Cypress.Commands.add('interceptGraphqlOperation', (operationName, fixturePath) => { + const graphqlInterceptions = Cypress.env('graphqlInterceptions') + cy.intercept(/(?:interface|beta)\.gateway\.uniswap\.org\/v1\/graphql/, (req) => { + req.headers['origin'] = 'https://app.uniswap.org' + const currentOperationName = req.body.operationName + + if (graphqlInterceptions.has(currentOperationName)) { + const fixturePath = graphqlInterceptions.get(currentOperationName) + req.reply({ fixture: fixturePath }) + } else { + req.continue() + } + }).as(operationName) + + graphqlInterceptions.set(operationName, fixturePath) + }) + + Cypress.Commands.add('interceptQuoteRequest', (fixturePath) => { + return cy.intercept(/(?:interface|beta)\.gateway\.uniswap\.org\/v2\/quote/, (req) => { + req.headers['origin'] = 'https://app.uniswap.org' + req.reply({ fixture: fixturePath }) + }) + }) +} diff --git a/apps/web/functions/api/image/positions.tsx b/apps/web/functions/api/image/positions.tsx index 2a806e59710..15480ac42e4 100644 --- a/apps/web/functions/api/image/positions.tsx +++ b/apps/web/functions/api/image/positions.tsx @@ -32,7 +32,7 @@ export async function positionImageHandler(c: Context) { versionBadge: data.poolData?.protocolVersion, }) } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error) - return new Response(message, { status: 500 }) + console.error('positionImageHandler failed', error) + return new Response('Internal server error.', { status: 500 }) } } diff --git a/apps/web/public/app-sitemap.xml b/apps/web/public/app-sitemap.xml index bc83f2fc08c..06bb6000f83 100644 --- a/apps/web/public/app-sitemap.xml +++ b/apps/web/public/app-sitemap.xml @@ -126,4 +126,16 @@ weekly 0.5 + + https://app.uniswap.org/positions/create + 2024-09-17T19:57:27.976Z + weekly + 0.7 + + + https://app.uniswap.org/positions + 2024-09-17T19:57:27.976Z + weekly + 0.7 + diff --git a/apps/web/public/nfts-sitemap.xml b/apps/web/public/nfts-sitemap.xml index 384bc3a2dfd..ad5990a816c 100644 --- a/apps/web/public/nfts-sitemap.xml +++ b/apps/web/public/nfts-sitemap.xml @@ -2,707 +2,667 @@ https://app.uniswap.org/nfts/collection/0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x60e4d786628fea6478f785a6d7e704777c86a7c6 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xed5af388653567af2f388e6224dc7c4b3241c544 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x34d85c9cdeb23fa97cb08333b511ac86e1c4e258 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x99a9b7c1116f9ceeb1652de04d5969cce509b069 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x49cf6f5d44e70224e2e23fdcdd2c053f30ada28b - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xb7f7f6c52f2e2fdb1963eab30438024864c313f6 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x23581767a106ae21c074b2276d25e5c3e136a68b - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x8a90cab2b38dba80c64b7734e58ee1db38b8992e - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xba30e5f9bb24caa003e9f2f0497ad287fdf95623 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xbd3531da5cf5857e7cfaa92426877b022e612cf8 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x7bd29408f11d2bfc23c34f18275bbf23bb716bc7 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x306b1ea3ecdf94ab739f1910bbda052ed4a9f949 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x1a92f7381b9f03921564a437210bb9396471050c - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x5cc5b05a8a13e3fbdb0bb9fccd98d38e50f90c38 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x5af0d9827e0c53e4799bb226655a1de152a425a5 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x3bf2922f4520a8ba0c2efc3d2a1539678dad5e9d - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xe785e82358879f061bc3dcac6f0444462d4b5330 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x76be3b62873462d2142405439777e971754e8e77 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xfd43af6d3fe1b916c026f6ac35b3ede068d1ca01 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x1cb1a5e65610aeff2551a50f76a87a7d3fb649c6 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xff9c1b15b16263c61d017ee9f65c50e4ae0113d7 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x6339e5e072086621540d0362c4e3cea0d643e114 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xb932a70a57673d89f4acffbe830e8ed7f75fb9e0 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x79fcdef22feed20eddacbb2587640e45491b757f - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xa3aee8bce55beea1951ef834b99f3ac60d1abeeb - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x769272677fab02575e84945f03eca517acc544cc - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x4db1f25d3d98600140dfc18deb7515be5bd293af - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x34eebee6942d8def3c125458d1a86e0a897fd6f9 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x59468516a8259058bad1ca5f8f4bff190d30e066 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x394e3d3044fc89fcdd966d3cb35ac0b32b0cda91 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x60bb1e2aa1c9acafb4d34f71585d7e959f387769 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x28472a58a490c5e09a238847f66a68a47cc76f0f - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x341a1c534248966c4b6afad165b98daed4b964ef - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x82c7a8f707110f5fbb16184a5933e9f78a34c6ab - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xccc441ac31f02cd96c153db6fd5fe0a2f4e6a68d - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x764aeebcf425d56800ef2c84f2578689415a2daa - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x160c404b2b49cbc3240055ceaee026df1e8497a0 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xd2f668a8461d6761115daf8aeb3cdf5f40c532c6 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x39ee2c7b3cb80254225884ca001f57118c8f21b6 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xd774557b647330c91bf44cfeab205095f7e6c367 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x1792a96e5668ad7c167ab804a100ce42395ce54d - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xf87e31492faf9a91b02ee0deaad50d51d56d5d4d - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x04afa589e2b933f9463c5639f412b183ec062505 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xe75512aa3bec8f00434bbd6ad8b0a3fbff100ad6 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x348fc118bcc65a92dc033a951af153d14d945312 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x892848074ddea461a15f337250da3ce55580ca85 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x5946aeaab44e65eb370ffaa6a7ef2218cff9b47d - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x282bdd42f4eb70e7a9d9f40c8fea0825b7f68c5d - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x4b15a9c28034dc83db40cd810001427d3bd7163d - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x7ea3cca10668b8346aec0bf1844a49e995527c8b - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xb852c6b5892256c264cc2c888ea462189154d8d7 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x9378368ba6b85c1fba5b131b530f5f5bedf21a18 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x2acab3dea77832c09420663b0e1cb386031ba17b - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x0c2e57efddba8c768147d1fdf9176a0a6ebd5d83 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x08d7c0242953446436f34b4c78fe9da38c73668d - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x8943c7bac1914c9a7aba750bf2b6b09fd21037e0 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x364c828ee171616a39897688a831c2499ad972ec - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x7f36182dee28c45de6072a34d29855bae76dbe2f - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xf61f24c2d93bf2de187546b14425bf631f28d6dc - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x797a48c46be32aafcedcfd3d8992493d8a1f256b - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x123b30e25973fecd8354dd5f41cc45a3065ef88c - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x6632a9d63e142f17a668064d41a21193b49b41a0 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xf4ee95274741437636e748ddac70818b4ed7d043 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x57a204aa1042f6e66dd7730813f4024114d74f37 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xd1258db6ac08eb0e625b75b371c023da478e94a9 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x75e95ba5997eb235f40ecf8347cdb11f18ff640b - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xd532b88607b1877fe20c181cba2550e3bbd6b31c - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xa1d4657e0e6507d5a94d06da93e94dc7c8c44b51 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xedb61f74b0d09b2558f1eeb79b247c1f363ae452 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x7d8820fa92eb1584636f4f5b8515b5476b75171a - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x231d3559aa848bf10366fb9868590f01d34bf240 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xad9fd7cb4fc7a0fbce08d64068f60cbde22ed34c - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x0e9d6552b85be180d941f1ca73ae3e318d2d4f1f - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xb716600ed99b4710152582a124c697a7fe78adbf - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xaadc2d4261199ce24a4b0a57370c4fcf43bb60aa - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x4e1f41613c9084fdb9e34e11fae9412427480e56 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x79986af15539de2db9a5086382daeda917a9cf0c - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xc99c679c50033bbc5321eb88752e89a93e9e83c5 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xc36cf0cfcb5d905b8b513860db0cfe63f6cf9f5c - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x9c8ff314c9bc7f6e59a9d9225fb22946427edc03 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x3110ef5f612208724ca51f5761a69081809f03b7 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x036721e5a769cc48b3189efbb9cce4471e8a48b1 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x524cab2ec69124574082676e6f654a18df49a048 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x7ab2352b1d2e185560494d5e577f9d3c238b78c5 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x32973908faee0bf825a343000fe412ebe56f802a - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x7daec605e9e2a1717326eedfd660601e2753a057 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xc1caf0c19a8ac28c41fe59ba6c754e4b9bd54de9 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x33fd426905f149f8376e227d0c9d3340aad17af1 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x466cfcd0525189b573e794f554b8a751279213ac - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x6be69b2a9b153737887cfcdca7781ed1511c7e36 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x80336ad7a747236ef41f47ed2c7641828a480baa - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x9401518f4ebba857baa879d9f76e1cc8b31ed197 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x4b61413d4392c806e6d0ff5ee91e6073c21d6430 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xc3f733ca98e0dad0386979eb96fb1722a1a05e69 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x09233d553058c2f42ba751c87816a8e9fae7ef10 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x960b7a6bcd451c9968473f7bbfd9be826efd549a - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x36d30b3b85255473d27dd0f7fd8f35e36a9d6f06 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x698fbaaca64944376e2cdc4cad86eaa91362cf54 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x497a9a79e82e6fc0ff10a16f6f75e6fcd5ae65a8 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x41a322b28d0ff354040e2cbc676f0320d8c8850d - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xa9c0a07a7cb84ad1f2ffab06de3e55aab7d523e8 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x942bc2d3e7a589fe5bd4a5c6ef9727dfd82f5c8a - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xb47e3cd837ddf8e4c57f05d70ab865de6e193bbb - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x8821bee2ba0df28761afff119d66390d594cd280 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x8c6def540b83471664edc6d5cf75883986932674 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x8d9710f0e193d3f95c0723eaaf1a81030dc9116d - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x86825dfca7a6224cfbd2da48e85df2fc3aa7c4b1 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x629a673a8242c2ac4b7b8c5d8735fbeac21a6205 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x9a534628b4062e123ce7ee2222ec20b86e16ca8f - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xc2c747e0f7004f9e8817db2ca4997657a7746928 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x73da73ef3a6982109c4d5bdb0db9dd3e3783f313 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xc92ceddfb8dd984a89fb494c376f9a48b999aafc - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x3248e8ba90facc4fdd3814518c14f8cc4d980e4b - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x67d9417c9c3c250f61a83c7e8658dac487b56b09 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xb6a37b5d14d502c3ab0ae6f3a0e058bc9517786e - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x86c10d10eca1fca9daf87a279abccabe0063f247 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x4b3406a41399c7fd2ba65cbc93697ad9e7ea61e5 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xb0640e8b5f24bedc63c33d371923d68fde020303 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xd3d9ddd0cf0a5f0bfb8f7fceae075df687eaebab - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xa5c0bd78d1667c13bfb403e2a3336871396713c5 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x4d7d2e237d64d1484660b55c0a4cc092fa5e6716 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xfcb1315c4273954f74cb16d5b663dbf479eec62e - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x66d1db16101502ed0ca428842c619ca7b62c8fef - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x128675d4fddbc4a0d3f8aa777d8ee0fb8b427c2f - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x19b86299c21505cdf59ce63740b240a9c822b5e4 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xacf63e56fd08970b43401492a02f6f38b6635c91 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x0bebad1ff25c623dff9605dad4a8f782d5da37df - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0xdceaf1652a131f32a821468dc03a92df0edd86ea - 2025-04-11T20:35:02.652Z - 0.7 - - - https://app.uniswap.org/nfts/collection/0x273f7f8e6489682df756151f5525576e322d51a3 - 2025-04-11T20:35:02.652Z - 0.7 - - - https://app.uniswap.org/nfts/collection/0x77372a4cc66063575b05b44481f059be356964a4 - 2025-04-11T20:35:02.652Z - 0.7 - - - https://app.uniswap.org/nfts/collection/0xf5b0a3efb8e8e4c201e2a935f110eaaf3ffecb8d - 2025-04-11T20:35:02.652Z - 0.7 - - - https://app.uniswap.org/nfts/collection/0x22c36bfdcef207f9c0cc941936eff94d4246d14a - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x59325733eb952a92e069c87f0a6168b29e80627f - 2025-04-11T20:35:02.652Z - 0.7 - - - https://app.uniswap.org/nfts/collection/0x0e3a2a1f2146d86a604adc220b4967a898d7fe07 - 2025-04-11T20:35:02.652Z - 0.7 - - - https://app.uniswap.org/nfts/collection/0x3af2a97414d1101e2107a70e7f33955da1346305 - 2025-04-11T20:35:02.652Z - 0.7 - - - https://app.uniswap.org/nfts/collection/0x5ab21ec0bfa0b29545230395e3adaca7d552c948 - 2025-04-11T20:35:02.652Z - 0.7 - - - https://app.uniswap.org/nfts/collection/0x617913dd43dbdf4236b85ec7bdf9adfd7e35b340 - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 - https://app.uniswap.org/nfts/collection/0x3fe1a4c1481c8351e91b64d5c398b159de07cbc5 - 2025-04-11T20:35:02.652Z + https://app.uniswap.org/nfts/collection/0xd4e4078ca3495de5b1d4db434bebc5a986197782 + 2025-03-20T21:18:53.078Z 0.7 - https://app.uniswap.org/nfts/collection/0xd4e4078ca3495de5b1d4db434bebc5a986197782 - 2025-04-11T20:35:02.652Z + https://app.uniswap.org/nfts/collection/0x77372a4cc66063575b05b44481f059be356964a4 + 2025-03-20T21:18:53.078Z 0.7 https://app.uniswap.org/nfts/collection/0x062e691c2054de82f28008a8ccc6d7a1c8ce060d - 2025-04-11T20:35:02.652Z + 2025-03-20T21:18:53.078Z 0.7 \ No newline at end of file diff --git a/apps/web/public/sitemap.xml b/apps/web/public/sitemap.xml index f344621757a..b035ec1e5db 100644 --- a/apps/web/public/sitemap.xml +++ b/apps/web/public/sitemap.xml @@ -9,7 +9,4 @@ https://app.uniswap.org/pools-sitemap.xml - - https://app.uniswap.org/nfts-sitemap.xml - diff --git a/apps/web/src/appGraphql/data/apollo/client.ts b/apps/web/src/appGraphql/data/apollo/client.ts index 4e06cc183bc..1ce7c50991e 100644 --- a/apps/web/src/appGraphql/data/apollo/client.ts +++ b/apps/web/src/appGraphql/data/apollo/client.ts @@ -1,3 +1,4 @@ +import { getRetryLink } from 'appGraphql/data/apollo/retryLink' import { ApolloClient, from, HttpLink } from '@apollo/client' import { setupSharedApolloCache } from 'uniswap/src/data/cache' import { getDatadogApolloLink } from 'utilities/src/logger/datadog/datadogLink' diff --git a/apps/web/src/assets/images/portfolio-page-disconnected-preview/dark.svg b/apps/web/src/assets/images/portfolio-page-disconnected-preview/dark.svg new file mode 100644 index 00000000000..1ae6e3cb76e --- /dev/null +++ b/apps/web/src/assets/images/portfolio-page-disconnected-preview/dark.svg @@ -0,0 +1,778 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/assets/images/portfolio-page-disconnected-preview/light.svg b/apps/web/src/assets/images/portfolio-page-disconnected-preview/light.svg new file mode 100644 index 00000000000..8e9c0579fe4 --- /dev/null +++ b/apps/web/src/assets/images/portfolio-page-disconnected-preview/light.svg @@ -0,0 +1,778 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/assets/images/portfolio-page-disconnected-preview/mobile-dark.svg b/apps/web/src/assets/images/portfolio-page-disconnected-preview/mobile-dark.svg new file mode 100644 index 00000000000..843ed723d5d --- /dev/null +++ b/apps/web/src/assets/images/portfolio-page-disconnected-preview/mobile-dark.svg @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/assets/images/portfolio-page-disconnected-preview/mobile-light.svg b/apps/web/src/assets/images/portfolio-page-disconnected-preview/mobile-light.svg new file mode 100644 index 00000000000..9530c38d4e9 --- /dev/null +++ b/apps/web/src/assets/images/portfolio-page-disconnected-preview/mobile-light.svg @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/assets/images/portfolio-page-promo/dark.svg b/apps/web/src/assets/images/portfolio-page-promo/dark.svg new file mode 100644 index 00000000000..1ae6e3cb76e --- /dev/null +++ b/apps/web/src/assets/images/portfolio-page-promo/dark.svg @@ -0,0 +1,778 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/assets/images/portfolio-page-promo/light.svg b/apps/web/src/assets/images/portfolio-page-promo/light.svg new file mode 100644 index 00000000000..8e9c0579fe4 --- /dev/null +++ b/apps/web/src/assets/images/portfolio-page-promo/light.svg @@ -0,0 +1,778 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/assets/svg/Emblem/A.svg b/apps/web/src/assets/svg/Emblem/A.svg new file mode 100644 index 00000000000..46c5ecdf931 --- /dev/null +++ b/apps/web/src/assets/svg/Emblem/A.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/src/assets/svg/Emblem/B.svg b/apps/web/src/assets/svg/Emblem/B.svg new file mode 100644 index 00000000000..9ba0cad2077 --- /dev/null +++ b/apps/web/src/assets/svg/Emblem/B.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/src/assets/svg/Emblem/C.svg b/apps/web/src/assets/svg/Emblem/C.svg new file mode 100644 index 00000000000..df525ee3977 --- /dev/null +++ b/apps/web/src/assets/svg/Emblem/C.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/assets/svg/Emblem/D.svg b/apps/web/src/assets/svg/Emblem/D.svg new file mode 100644 index 00000000000..6673c60e7b6 --- /dev/null +++ b/apps/web/src/assets/svg/Emblem/D.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/src/assets/svg/Emblem/E.svg b/apps/web/src/assets/svg/Emblem/E.svg new file mode 100644 index 00000000000..f1d262aa1fd --- /dev/null +++ b/apps/web/src/assets/svg/Emblem/E.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/src/assets/svg/Emblem/F.svg b/apps/web/src/assets/svg/Emblem/F.svg new file mode 100644 index 00000000000..f7f9944dbfa --- /dev/null +++ b/apps/web/src/assets/svg/Emblem/F.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/src/assets/svg/Emblem/G.svg b/apps/web/src/assets/svg/Emblem/G.svg new file mode 100644 index 00000000000..44e41f65357 --- /dev/null +++ b/apps/web/src/assets/svg/Emblem/G.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/src/assets/svg/Emblem/default.svg b/apps/web/src/assets/svg/Emblem/default.svg new file mode 100644 index 00000000000..1839d363511 --- /dev/null +++ b/apps/web/src/assets/svg/Emblem/default.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/MiniPortfolioV2.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/MiniPortfolioV2.tsx new file mode 100644 index 00000000000..534ce7caa71 --- /dev/null +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/MiniPortfolioV2.tsx @@ -0,0 +1,84 @@ +import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' +import { filterTransactionDetailsFromActivityItems } from 'pages/Portfolio/Activity/Filters/utils' +import { ViewAllButton } from 'pages/Portfolio/Overview/ViewAllButton' +import { useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router' +import { Button, Flex, Text } from 'ui/src' +import { RightArrow } from 'ui/src/components/icons/RightArrow' +import { iconSizes } from 'ui/src/theme' +import { ActivityItem } from 'uniswap/src/components/activity/generateActivityItemRenderer' +import { useActivityData } from 'uniswap/src/features/activity/hooks/useActivityData' +import { ElementName } from 'uniswap/src/features/telemetry/constants' +import { filterDefinedWalletAddresses } from 'utils/filterDefinedWalletAddresses' + +const MAX_RECENT_ACTIVITY_ITEMS = 3 + +export default function MiniPortfolioV2({ evmAddress, svmAddress }: { evmAddress?: string; svmAddress?: string }) { + const { t } = useTranslation() + const navigate = useNavigate() + const accountDrawer = useAccountDrawer() + + const handleViewPortfolio = useCallback(() => { + navigate('/portfolio') + accountDrawer.close() + }, [navigate, accountDrawer]) + + const handleViewActivity = useCallback(() => { + navigate('/portfolio/activity') + accountDrawer.close() + }, [navigate, accountDrawer]) + + const { renderActivityItem, sectionData } = useActivityData({ + evmOwner: evmAddress, + svmOwner: svmAddress, + ownerAddresses: filterDefinedWalletAddresses([evmAddress, svmAddress]), + fiatOnRampParams: undefined, + skip: false, + }) + + const recentActivityItems = useMemo(() => { + // Filter out section headers and loading items, then get the first 3 actual activity items + const actualActivityItems = filterTransactionDetailsFromActivityItems(sectionData ?? []).slice( + 0, + MAX_RECENT_ACTIVITY_ITEMS, + ) + return actualActivityItems.map((item: ActivityItem, index) => { + return renderActivityItem({ + item, + index, + }) + }) + }, [sectionData, renderActivityItem]) + + return ( + + + + + + {t('activity.recentActivity')} + + {recentActivityItems} + + + + + ) +} diff --git a/apps/web/src/components/AccountDrawer/ReportedActivityToggle.tsx b/apps/web/src/components/AccountDrawer/ReportedActivityToggle.tsx new file mode 100644 index 00000000000..c2798ccdac6 --- /dev/null +++ b/apps/web/src/components/AccountDrawer/ReportedActivityToggle.tsx @@ -0,0 +1,24 @@ +import { SettingsToggle } from 'components/AccountDrawer/SettingsToggle' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { useHideReportedActivitySetting } from 'uniswap/src/features/settings/hooks' +import { setHideReportedActivity } from 'uniswap/src/features/settings/slice' + +export function ReportedActivityToggle() { + const { t } = useTranslation() + const hideReportedActivity = useHideReportedActivitySetting() + const dispatch = useDispatch() + + const onToggle = () => { + dispatch(setHideReportedActivity(!hideReportedActivity)) + } + + return ( + + ) +} diff --git a/apps/web/src/components/ActionTiles/SwapActionTile.tsx b/apps/web/src/components/ActionTiles/SwapActionTile.tsx new file mode 100644 index 00000000000..ed1f11352c7 --- /dev/null +++ b/apps/web/src/components/ActionTiles/SwapActionTile.tsx @@ -0,0 +1,30 @@ +import { ActionTileWithIconAnimation } from 'components/ActionTiles/ActionTileWithIconAnimation' +import { useTranslation } from 'react-i18next' +import { CoinConvert } from 'ui/src/components/icons/CoinConvert' +import { FlexProps } from 'ui/src/components/layout/Flex' +import { useUniswapContext } from 'uniswap/src/contexts/UniswapContext' +import { ElementName } from 'uniswap/src/features/telemetry/constants' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { useEvent } from 'utilities/src/react/hooks' + +export function SwapActionTile({ padding = '$spacing12' }: { padding?: FlexProps['p'] }) { + const { t } = useTranslation() + const { navigateToSwapFlow } = useUniswapContext() + + const onPressSwap = useEvent(() => { + navigateToSwapFlow({}) + }) + + return ( + + + + ) +} diff --git a/apps/web/src/components/ActivityTable/ActivityAddressCell.tsx b/apps/web/src/components/ActivityTable/ActivityAddressCell.tsx new file mode 100644 index 00000000000..b47c5acf5cd --- /dev/null +++ b/apps/web/src/components/ActivityTable/ActivityAddressCell.tsx @@ -0,0 +1,27 @@ +import { AddressWithAvatar } from 'components/ActivityTable/AddressWithAvatar' +import { buildActivityRowFragments } from 'components/ActivityTable/registry' +import { Flex } from 'ui/src' +import { ArrowRight } from 'ui/src/components/icons/ArrowRight' +import { TransactionDetails } from 'uniswap/src/features/transactions/types/transactionDetails' +import { getValidAddress } from 'uniswap/src/utils/addresses' + +interface ActivityAddressCellProps { + transaction: TransactionDetails +} + +export function ActivityAddressCell({ transaction }: ActivityAddressCellProps) { + const { counterparty } = buildActivityRowFragments(transaction) + + // Use counterparty from adapter if available, otherwise fall back to from address + const rawAddress = counterparty ?? transaction.from + const otherPartyAddress = rawAddress ? getValidAddress({ address: rawAddress, chainId: transaction.chainId }) : null + + return ( + + {otherPartyAddress && } + + + + + ) +} diff --git a/apps/web/src/components/ActivityTable/ActivityAmountCell.tsx b/apps/web/src/components/ActivityTable/ActivityAmountCell.tsx new file mode 100644 index 00000000000..b3cddbac36b --- /dev/null +++ b/apps/web/src/components/ActivityTable/ActivityAmountCell.tsx @@ -0,0 +1,255 @@ +import { buildActivityRowFragments } from 'components/ActivityTable/registry' +import { TokenAmountDisplay } from 'components/ActivityTable/TokenAmountDisplay' +import { useTranslation } from 'react-i18next' +import { Flex, Text } from 'ui/src' +import { ArrowRight } from 'ui/src/components/icons/ArrowRight' +import { useFormattedCurrencyAmountAndUSDValue } from 'uniswap/src/components/activity/hooks/useFormattedCurrencyAmountAndUSDValue' +import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' +import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' +import { + useCurrencyInfo, + useNativeCurrencyInfo, + useWrappedNativeCurrencyInfo, +} from 'uniswap/src/features/tokens/useCurrencyInfo' +import { TransactionDetails } from 'uniswap/src/features/transactions/types/transactionDetails' +import { getSymbolDisplayText } from 'uniswap/src/utils/currency' +import { NumberType } from 'utilities/src/format/types' + +interface ActivityAmountCellProps { + transaction: TransactionDetails +} + +function EmptyCell() { + return ( + + — + + ) +} + +interface DualTokenLayoutProps { + inputCurrency: CurrencyInfo | null | undefined + outputCurrency: CurrencyInfo | null | undefined + inputFormattedAmount: string | null + outputFormattedAmount: string | null + inputUsdValue: string | null + outputUsdValue: string | null + separator?: React.ReactNode +} + +function Separator({ children }: { children: React.ReactNode }) { + return ( + + {typeof children === 'string' ? ( + + {children} + + ) : ( + children + )} + + ) +} + +function DualTokenLayout({ + inputCurrency, + outputCurrency, + inputFormattedAmount, + outputFormattedAmount, + inputUsdValue, + outputUsdValue, + separator = , +}: DualTokenLayoutProps) { + return ( + + + {separator} + + + ) +} + +function formatAmountWithSymbol(amount: string | undefined, symbol: string | undefined): string | null { + return amount ? `${amount}${getSymbolDisplayText(symbol)}` : null +} + +function getUsdValue(value: string | undefined): string | null { + return value !== '-' ? (value ?? null) : null +} + +export function ActivityAmountCell({ transaction }: ActivityAmountCellProps) { + const formatter = useLocalizationContext() + const { t } = useTranslation() + const { chainId } = transaction + const { amount } = buildActivityRowFragments(transaction) + + // Hook up currency info based on amount model + const inputCurrencyInfo = useCurrencyInfo(amount?.kind === 'pair' ? amount.inputCurrencyId : undefined) + const outputCurrencyInfo = useCurrencyInfo(amount?.kind === 'pair' ? amount.outputCurrencyId : undefined) + const singleCurrencyInfo = useCurrencyInfo( + amount?.kind === 'single' || amount?.kind === 'approve' ? amount.currencyId : undefined, + ) + const currency0Info = useCurrencyInfo(amount?.kind === 'liquidity-pair' ? amount.currency0Id : undefined) + const currency1Info = useCurrencyInfo(amount?.kind === 'liquidity-pair' ? amount.currency1Id : undefined) + + const nativeCurrencyInfo = useNativeCurrencyInfo(chainId) + const wrappedCurrencyInfo = useWrappedNativeCurrencyInfo(chainId) + + // Format amounts based on kind + const inputFormattedData = useFormattedCurrencyAmountAndUSDValue({ + currency: inputCurrencyInfo?.currency, + currencyAmountRaw: amount?.kind === 'pair' ? (amount.inputAmountRaw ?? '') : '', + formatter, + isApproximateAmount: false, + }) + + const outputFormattedData = useFormattedCurrencyAmountAndUSDValue({ + currency: outputCurrencyInfo?.currency, + currencyAmountRaw: amount?.kind === 'pair' ? (amount.outputAmountRaw ?? '') : '', + formatter, + isApproximateAmount: false, + }) + + const singleFormattedData = useFormattedCurrencyAmountAndUSDValue({ + currency: singleCurrencyInfo?.currency, + currencyAmountRaw: amount?.kind === 'single' ? (amount.amountRaw ?? '') : '', + formatter, + isApproximateAmount: false, + }) + + const wrapAmountRaw = amount?.kind === 'wrap' ? (amount.amountRaw ?? '') : '' + const wrapInputCurrency = amount?.kind === 'wrap' && amount.unwrapped ? wrappedCurrencyInfo : nativeCurrencyInfo + const wrapOutputCurrency = amount?.kind === 'wrap' && amount.unwrapped ? nativeCurrencyInfo : wrappedCurrencyInfo + + const wrapInputFormattedData = useFormattedCurrencyAmountAndUSDValue({ + currency: wrapInputCurrency?.currency, + currencyAmountRaw: wrapAmountRaw, + formatter, + isApproximateAmount: false, + }) + + const wrapOutputFormattedData = useFormattedCurrencyAmountAndUSDValue({ + currency: wrapOutputCurrency?.currency, + currencyAmountRaw: wrapAmountRaw, + formatter, + isApproximateAmount: false, + }) + + const currency0FormattedData = useFormattedCurrencyAmountAndUSDValue({ + currency: currency0Info?.currency, + currencyAmountRaw: amount?.kind === 'liquidity-pair' ? amount.currency0AmountRaw : '', + formatter, + isApproximateAmount: false, + }) + + const currency1FormattedData = useFormattedCurrencyAmountAndUSDValue({ + currency: currency1Info?.currency, + currencyAmountRaw: amount?.kind === 'liquidity-pair' ? (amount.currency1AmountRaw ?? '') : '', + formatter, + isApproximateAmount: false, + }) + + if (!amount) { + return + } + + // Guard against missing currency data before formatting + if (amount.kind === 'pair' && (!inputCurrencyInfo || !outputCurrencyInfo)) { + return + } + + if (amount.kind === 'liquidity-pair' && (!currency0Info || !currency1Info)) { + return + } + + switch (amount.kind) { + case 'pair': { + // Dual token layout for swaps and bridges: Token1 → Token2 + return ( + + ) + } + + case 'approve': { + // Single token layout for approvals + let formattedAmount: string | null = null + + if (singleCurrencyInfo && amount.approvalAmount !== undefined) { + const amountText = + amount.approvalAmount === 'INF' + ? t('transaction.amount.unlimited') + : amount.approvalAmount && amount.approvalAmount !== '0.0' + ? formatter.formatNumberOrString({ value: amount.approvalAmount, type: NumberType.TokenNonTx }) + : '' + + formattedAmount = `${amountText ? amountText + ' ' : ''}${getSymbolDisplayText(singleCurrencyInfo.currency.symbol) ?? ''}` + } + + return + } + + case 'wrap': { + // Dual token layout for wraps: ETH ↔ WETH + return ( + + ) + } + + case 'single': { + // Single token layout for transfers + return ( + + ) + } + + case 'liquidity-pair': { + // Dual token layout for liquidity: Token0 and Token1 + return ( + + ) + } + } +} diff --git a/apps/web/src/components/ActivityTable/ActivityTable.tsx b/apps/web/src/components/ActivityTable/ActivityTable.tsx new file mode 100644 index 00000000000..97b62e13d1d --- /dev/null +++ b/apps/web/src/components/ActivityTable/ActivityTable.tsx @@ -0,0 +1,123 @@ +import { createColumnHelper, Row } from '@tanstack/react-table' +import { ActivityAddressCell } from 'components/ActivityTable/ActivityAddressCell' +import { ActivityAmountCell } from 'components/ActivityTable/ActivityAmountCell' +import { TimeCell } from 'components/ActivityTable/TimeCell' +import { TransactionTypeCell } from 'components/ActivityTable/TransactionTypeCell' +import { Table } from 'components/Table' +import { Cell } from 'components/Table/Cell' +import { HeaderCell } from 'components/Table/styled' +import { memo, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { Text } from 'ui/src' +import { TransactionDetails } from 'uniswap/src/features/transactions/types/transactionDetails' + +interface ActivityTableProps { + data: TransactionDetails[] + loading?: boolean + error?: boolean + rowWrapper?: (row: Row, content: JSX.Element) => JSX.Element +} + +function _ActivityTable({ data, loading = false, error = false, rowWrapper }: ActivityTableProps): JSX.Element { + const { t } = useTranslation() + const columnHelper = useMemo(() => createColumnHelper(), []) + const showLoadingSkeleton = loading || error + + const columns = useMemo( + () => [ + // Time Column + columnHelper.accessor('addedTime', { + header: () => ( + + + {t('portfolio.activity.table.column.time')} + + + ), + cell: (info) => { + if (showLoadingSkeleton) { + return + } + return ( + + + + ) + }, + }), + + // Type Column + columnHelper.accessor((row) => row.typeInfo.type, { + id: 'type', + header: () => ( + + + {t('portfolio.activity.table.column.type')} + + + ), + cell: (info) => { + if (showLoadingSkeleton) { + return + } + return ( + + + + ) + }, + }), + + // Amount Column + columnHelper.display({ + id: 'amount', + header: () => ( + + + {t('portfolio.activity.table.column.amount')} + + + ), + cell: (info) => { + if (showLoadingSkeleton) { + return + } + return ( + + + + ) + }, + minSize: 280, + size: 300, + }), + + // Address Column + columnHelper.display({ + id: 'address', + header: () => ( + + + {t('portfolio.activity.table.column.address')} + + + ), + cell: (info) => { + if (showLoadingSkeleton) { + return + } + return ( + + + + ) + }, + }), + ], + [t, columnHelper, showLoadingSkeleton], + ) + + return +} + +export const ActivityTable = memo(_ActivityTable) diff --git a/apps/web/src/components/ActivityTable/AddressWithAvatar.tsx b/apps/web/src/components/ActivityTable/AddressWithAvatar.tsx new file mode 100644 index 00000000000..4abee3d5de7 --- /dev/null +++ b/apps/web/src/components/ActivityTable/AddressWithAvatar.tsx @@ -0,0 +1,42 @@ +import { Flex, Text } from 'ui/src' +import { Unitag } from 'ui/src/components/icons/Unitag' +import { useUnitagsAddressQuery } from 'uniswap/src/data/apiClients/unitagsApi/useUnitagsAddressQuery' +import { AccountIcon } from 'uniswap/src/features/accounts/AccountIcon' +import { useENSName } from 'uniswap/src/features/ens/api' +import { shortenAddress } from 'utilities/src/addresses' + +interface AddressWithAvatarProps { + address: Address + size?: number + showAvatar?: boolean +} + +export function AddressWithAvatar({ address, size = 20, showAvatar = true }: AddressWithAvatarProps) { + const { data: ENSName } = useENSName(address) + const { data: unitag } = useUnitagsAddressQuery({ + params: address ? { address } : undefined, + }) + const uniswapUsername = unitag?.username + + const displayName = uniswapUsername ?? ENSName ?? shortenAddress({ address }) + const hasUnitag = Boolean(uniswapUsername) + + return ( + + {showAvatar && ( + + )} + + {displayName} + + {hasUnitag && } + + ) +} diff --git a/apps/web/src/components/ActivityTable/TimeCell.tsx b/apps/web/src/components/ActivityTable/TimeCell.tsx new file mode 100644 index 00000000000..f90326cfbb8 --- /dev/null +++ b/apps/web/src/components/ActivityTable/TimeCell.tsx @@ -0,0 +1,15 @@ +import { TableText } from 'components/Table/styled' +import { useFormattedTimeForActivity } from 'uniswap/src/components/activity/hooks/useFormattedTime' + +interface TimeCellProps { + timestamp: number +} + +export function TimeCell({ timestamp }: TimeCellProps) { + const formattedTime = useFormattedTimeForActivity(timestamp) + return ( + + {formattedTime} + + ) +} diff --git a/apps/web/src/components/ActivityTable/TokenAmountDisplay.tsx b/apps/web/src/components/ActivityTable/TokenAmountDisplay.tsx new file mode 100644 index 00000000000..ef04e9384bf --- /dev/null +++ b/apps/web/src/components/ActivityTable/TokenAmountDisplay.tsx @@ -0,0 +1,32 @@ +import { TableText } from 'components/Table/styled' +import { Flex } from 'ui/src' +import { CurrencyLogo } from 'uniswap/src/components/CurrencyLogo/CurrencyLogo' +import { useCurrencyInfo } from 'uniswap/src/features/tokens/useCurrencyInfo' + +interface TokenAmountDisplayProps { + currencyInfo: ReturnType + formattedAmount: string | null + usdValue: string | null +} + +export function TokenAmountDisplay({ currencyInfo, formattedAmount, usdValue }: TokenAmountDisplayProps) { + if (!currencyInfo || !formattedAmount) { + return null + } + + return ( + + + + + {formattedAmount} + + {usdValue && ( + + {usdValue} + + )} + + + ) +} diff --git a/apps/web/src/components/ActivityTable/TransactionTypeCell.tsx b/apps/web/src/components/ActivityTable/TransactionTypeCell.tsx new file mode 100644 index 00000000000..5c3f52d05e7 --- /dev/null +++ b/apps/web/src/components/ActivityTable/TransactionTypeCell.tsx @@ -0,0 +1,30 @@ +import { buildActivityRowFragments } from 'components/ActivityTable/registry' +import { TableText } from 'components/Table/styled' +import { getTransactionTypeFilterOptions } from 'pages/Portfolio/Activity/Filters/utils' +import { useTranslation } from 'react-i18next' +import { Flex } from 'ui/src' +import { TransactionDetails } from 'uniswap/src/features/transactions/types/transactionDetails' + +interface TransactionTypeCellProps { + transaction: TransactionDetails +} + +export function TransactionTypeCell({ transaction }: TransactionTypeCellProps) { + const { t } = useTranslation() + const { typeLabel } = buildActivityRowFragments(transaction) + + // Get the icon from the filter options based on base group + const transactionTypeOptions = getTransactionTypeFilterOptions(t) + const typeOption = typeLabel?.baseGroup ? transactionTypeOptions[typeLabel.baseGroup] : null + const IconComponent = typeOption?.icon + + // Use override label key if provided, otherwise use the base group label + const label = typeLabel?.overrideLabelKey ? t(typeLabel.overrideLabelKey) : (typeOption?.label ?? 'Transaction') + + return ( + + {IconComponent && } + {label} + + ) +} diff --git a/apps/web/src/components/ActivityTable/activityTableModels.ts b/apps/web/src/components/ActivityTable/activityTableModels.ts new file mode 100644 index 00000000000..f1fc9d10af1 --- /dev/null +++ b/apps/web/src/components/ActivityTable/activityTableModels.ts @@ -0,0 +1,61 @@ +/** + * Models for activity table presentation layer. + * These types describe table-ready data from transaction parsers, without formatting or i18n. + * Each adapter returns raw IDs, amounts, addresses, and translation keys. + */ + +/** + * Represents the amount/token data for different transaction types + */ +type ActivityAmountModel = + | { + kind: 'pair' + inputCurrencyId: string + outputCurrencyId: string + inputAmountRaw?: string + outputAmountRaw?: string + } + | { + kind: 'single' + currencyId?: string + amountRaw?: string + } + | { + kind: 'approve' + currencyId?: string + approvalAmount?: string | 'INF' + } + | { + kind: 'wrap' + unwrapped: boolean + amountRaw?: string + } + | { + kind: 'liquidity-pair' + currency0Id: string + currency1Id: string + currency0AmountRaw: string + currency1AmountRaw?: string + } + +/** + * Represents the type label and grouping for a transaction + */ +interface ActivityTypeLabel { + /** Base group for filtering and icon mapping */ + baseGroup: 'swaps' | 'sent' | 'received' | 'deposits' | null + /** Optional override translation key for custom labels (e.g., "Wrapped"/"Unwrapped") */ + overrideLabelKey?: string +} + +/** + * Complete row data fragments for a single transaction in the activity table + */ +export interface ActivityRowFragments { + /** Amount/token data for the transaction */ + amount?: ActivityAmountModel | null + /** Counterparty address (sender/recipient/spender) */ + counterparty?: Address | null + /** Type label and grouping information */ + typeLabel?: ActivityTypeLabel | null +} diff --git a/apps/web/src/components/ActivityTable/registry.ts b/apps/web/src/components/ActivityTable/registry.ts new file mode 100644 index 00000000000..889b6d0451a --- /dev/null +++ b/apps/web/src/components/ActivityTable/registry.ts @@ -0,0 +1,246 @@ +import { UNI_ADDRESSES } from '@uniswap/sdk-core' +import { ActivityRowFragments } from 'components/ActivityTable/activityTableModels' +import { AssetType } from 'uniswap/src/entities/assets' +import { TransactionDetails, TransactionType } from 'uniswap/src/features/transactions/types/transactionDetails' +import { getValidAddress } from 'uniswap/src/utils/addresses' +import { buildCurrencyId } from 'uniswap/src/utils/currencyId' + +/** + * Builds activity row fragments for a transaction by mapping from parsed typeInfo. + * Returns empty object for unsupported transaction types. + * + * @param details - The transaction details with parsed typeInfo + * @returns Activity row fragments containing amount, counterparty, and type label data + */ +export function buildActivityRowFragments(details: TransactionDetails): ActivityRowFragments { + const { typeInfo, chainId } = details + + switch (typeInfo.type) { + case TransactionType.Swap: + return { + amount: { + kind: 'pair', + inputCurrencyId: typeInfo.inputCurrencyId, + outputCurrencyId: typeInfo.outputCurrencyId, + inputAmountRaw: 'inputCurrencyAmountRaw' in typeInfo ? typeInfo.inputCurrencyAmountRaw : undefined, + outputAmountRaw: 'outputCurrencyAmountRaw' in typeInfo ? typeInfo.outputCurrencyAmountRaw : undefined, + }, + counterparty: null, + typeLabel: { + baseGroup: 'swaps', + overrideLabelKey: 'transaction.status.swap.success', + }, + } + + case TransactionType.Bridge: + return { + amount: { + kind: 'pair', + inputCurrencyId: typeInfo.inputCurrencyId, + outputCurrencyId: typeInfo.outputCurrencyId, + inputAmountRaw: 'inputCurrencyAmountRaw' in typeInfo ? typeInfo.inputCurrencyAmountRaw : undefined, + outputAmountRaw: 'outputCurrencyAmountRaw' in typeInfo ? typeInfo.outputCurrencyAmountRaw : undefined, + }, + counterparty: null, + typeLabel: { + baseGroup: 'swaps', + }, + } + + case TransactionType.Send: { + const currencyId = + typeInfo.assetType === AssetType.Currency ? buildCurrencyId(chainId, typeInfo.tokenAddress) : undefined + + return { + amount: { + kind: 'single', + currencyId, + amountRaw: typeInfo.currencyAmountRaw, + }, + counterparty: typeInfo.recipient ? getValidAddress({ address: typeInfo.recipient, chainId }) : null, + typeLabel: { + baseGroup: 'sent', + }, + } + } + + case TransactionType.Receive: { + const currencyId = + typeInfo.assetType === AssetType.Currency ? buildCurrencyId(chainId, typeInfo.tokenAddress) : undefined + + return { + amount: { + kind: 'single', + currencyId, + amountRaw: typeInfo.currencyAmountRaw, + }, + counterparty: typeInfo.sender ? getValidAddress({ address: typeInfo.sender, chainId }) : null, + typeLabel: { + baseGroup: 'received', + }, + } + } + + case TransactionType.Approve: { + const currencyId = buildCurrencyId(chainId, typeInfo.tokenAddress) + + return { + amount: { + kind: 'approve', + currencyId, + approvalAmount: typeInfo.approvalAmount, + }, + counterparty: typeInfo.spender ? getValidAddress({ address: typeInfo.spender, chainId }) : null, + typeLabel: { + baseGroup: null, + overrideLabelKey: 'common.approved', + }, + } + } + + case TransactionType.Wrap: + return { + amount: { + kind: 'wrap', + unwrapped: typeInfo.unwrapped, + amountRaw: typeInfo.currencyAmountRaw, + }, + counterparty: null, + typeLabel: { + baseGroup: 'swaps', + overrideLabelKey: typeInfo.unwrapped ? 'common.unwrapped' : 'common.wrapped', + }, + } + + case TransactionType.CreatePool: + case TransactionType.CreatePair: + return { + amount: { + kind: 'liquidity-pair', + currency0Id: typeInfo.currency0Id, + currency1Id: typeInfo.currency1Id, + currency0AmountRaw: typeInfo.currency0AmountRaw, + currency1AmountRaw: typeInfo.currency1AmountRaw, + }, + counterparty: typeInfo.dappInfo?.address + ? getValidAddress({ address: typeInfo.dappInfo.address, chainId }) + : null, + typeLabel: { + baseGroup: null, + overrideLabelKey: 'pool.create', + }, + } + + case TransactionType.LiquidityIncrease: + return { + amount: { + kind: 'liquidity-pair', + currency0Id: typeInfo.currency0Id, + currency1Id: typeInfo.currency1Id, + currency0AmountRaw: typeInfo.currency0AmountRaw, + currency1AmountRaw: typeInfo.currency1AmountRaw, + }, + counterparty: typeInfo.dappInfo?.address + ? getValidAddress({ address: typeInfo.dappInfo.address, chainId }) + : null, + typeLabel: { + baseGroup: 'deposits', + overrideLabelKey: 'common.addLiquidity', + }, + } + + case TransactionType.LiquidityDecrease: + return { + amount: { + kind: 'liquidity-pair', + currency0Id: typeInfo.currency0Id, + currency1Id: typeInfo.currency1Id, + currency0AmountRaw: typeInfo.currency0AmountRaw, + currency1AmountRaw: typeInfo.currency1AmountRaw, + }, + counterparty: typeInfo.dappInfo?.address + ? getValidAddress({ address: typeInfo.dappInfo.address, chainId }) + : null, + typeLabel: { + baseGroup: null, + overrideLabelKey: 'pool.removeLiquidity', + }, + } + + case TransactionType.NFTMint: { + const currencyId = typeInfo.purchaseCurrencyId + return { + amount: { + kind: 'single', + currencyId, + amountRaw: typeInfo.purchaseCurrencyAmountRaw, + }, + counterparty: typeInfo.dappInfo?.address + ? getValidAddress({ address: typeInfo.dappInfo.address, chainId }) + : null, + typeLabel: { + baseGroup: null, + overrideLabelKey: 'transaction.status.mint.success', + }, + } + } + + case TransactionType.CollectFees: + return { + amount: typeInfo.currency1Id + ? { + kind: 'liquidity-pair', + currency0Id: typeInfo.currency0Id, + currency1Id: typeInfo.currency1Id, + currency0AmountRaw: typeInfo.currency0AmountRaw, + currency1AmountRaw: typeInfo.currency1AmountRaw, + } + : { + kind: 'single', + currencyId: typeInfo.currency0Id, + amountRaw: typeInfo.currency0AmountRaw, + }, + counterparty: null, + typeLabel: { + baseGroup: null, + overrideLabelKey: 'transaction.status.collected.fees', + }, + } + + case TransactionType.LPIncentivesClaimRewards: { + const currencyId = buildCurrencyId(chainId, typeInfo.tokenAddress) + return { + amount: { + kind: 'single', + currencyId, + amountRaw: undefined, + }, + counterparty: null, + typeLabel: { + baseGroup: null, + overrideLabelKey: 'transaction.status.collected.fees', + }, + } + } + + case TransactionType.ClaimUni: { + const tokenAddress = UNI_ADDRESSES[chainId] + const currencyId = tokenAddress ? buildCurrencyId(chainId, tokenAddress) : undefined + return { + amount: { + kind: 'single', + currencyId, + amountRaw: typeInfo.uniAmountRaw, + }, + counterparty: getValidAddress({ address: typeInfo.recipient, chainId }), + typeLabel: { + baseGroup: null, + overrideLabelKey: 'common.claimed', + }, + } + } + + default: + return {} + } +} diff --git a/apps/web/src/components/Banner/BridgingPopularTokens/BridgingPopularTokensBanner.tsx b/apps/web/src/components/Banner/BridgingPopularTokens/BridgingPopularTokensBanner.tsx new file mode 100644 index 00000000000..2957dd9ef47 --- /dev/null +++ b/apps/web/src/components/Banner/BridgingPopularTokens/BridgingPopularTokensBanner.tsx @@ -0,0 +1,108 @@ +import { SharedEventName } from '@uniswap/analytics-events' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router' +import { useAppDispatch } from 'state/hooks' +import { Flex, IconButton, Image, styled, Text, TouchableArea } from 'ui/src' +import { BRIDGED_ASSETS_V2_WEB_BANNER } from 'ui/src/assets' +import { X } from 'ui/src/components/icons/X' +import { useUniswapContext } from 'uniswap/src/contexts/UniswapContext' +import { setHasDismissedBridgedAssetsBannerV2 } from 'uniswap/src/features/behaviorHistory/slice' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { ElementName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { Trace } from 'uniswap/src/features/telemetry/Trace' + +const BRIDGING_POPULAR_TOKENS_BANNER_HEIGHT = 152 +const GRADIENT_BACKGROUND_HEIGHT = 64 +const BANNER_PADDING = 16 + +const BannerContainer = styled(TouchableArea, { + borderRadius: '$rounded16', + width: 260, + height: BRIDGING_POPULAR_TOKENS_BANNER_HEIGHT, + shadowColor: '$shadowColor', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.4, + shadowRadius: 10, + overflow: 'hidden', + padding: BANNER_PADDING, + backgroundColor: '$surface1', + borderWidth: 1, + borderColor: '$surface3', + gap: '$spacing16', + + '$platform-web': { + position: 'fixed', + bottom: 29, + left: 40, + }, +}) + +export function BridgingPopularTokensBanner() { + const dispatch = useAppDispatch() + const { t } = useTranslation() + const navigate = useNavigate() + const { setIsSwapTokenSelectorOpen, setSwapOutputChainId } = useUniswapContext() + + const handleBannerClose = useCallback(() => { + dispatch(setHasDismissedBridgedAssetsBannerV2(true)) + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + element: ElementName.CloseButton, + modal: ElementName.BridgedAssetsBannerV2, + }) + }, [dispatch]) + + const handleBannerClick = useCallback(() => { + navigate('/swap?outputChain=unichain') + setSwapOutputChainId(UniverseChainId.Unichain) + setIsSwapTokenSelectorOpen(true) + dispatch(setHasDismissedBridgedAssetsBannerV2(true)) + }, [dispatch, navigate, setIsSwapTokenSelectorOpen, setSwapOutputChainId]) + + return ( + + + + + + + + + {t('onboarding.home.intro.bridgedAssets.title')} + + + {t('bridgingPopularTokens.banner.description')} + + + + + ) +} + +function BannerXButton({ handleClose }: { handleClose: () => void }) { + return ( + + { + e.stopPropagation() + handleClose() + }} + hoverStyle={{ opacity: 0.8 }} + icon={} + p={2} + /> + + ) +} diff --git a/apps/web/src/components/Charts/LiquidityChart/utils/calculateAnchoredLiquidityByTick.test.ts b/apps/web/src/components/Charts/LiquidityChart/utils/calculateAnchoredLiquidityByTick.test.ts new file mode 100644 index 00000000000..a9e9477bdb0 --- /dev/null +++ b/apps/web/src/components/Charts/LiquidityChart/utils/calculateAnchoredLiquidityByTick.test.ts @@ -0,0 +1,223 @@ +import { calculateAnchoredLiquidityByTick } from 'components/Charts/LiquidityChart/utils/calculateAnchoredLiquidityByTick' +import JSBI from 'jsbi' +import { TickProcessed } from 'utils/computeSurroundingTicks' +import { describe, expect, it } from 'vitest' + +describe('calculateAnchoredLiquidityByTick', () => { + it('should calculate anchored liquidity correctly with simple values', () => { + const ticksProcessed: TickProcessed[] = [ + { + tick: 100, + liquidityNet: JSBI.BigInt(1000), + liquidityActive: JSBI.BigInt(0), + } as TickProcessed, + { + tick: 110, + liquidityNet: JSBI.BigInt(2000), + liquidityActive: JSBI.BigInt(0), + } as TickProcessed, + { + tick: 120, + liquidityNet: JSBI.BigInt(3000), + liquidityActive: JSBI.BigInt(0), + } as TickProcessed, + ] + + const activeTick = 110 + const liquidity = JSBI.BigInt(5000) + + const result = calculateAnchoredLiquidityByTick({ ticksProcessed, activeTick, liquidity }) + + // Step 1: Cumulative sum + // tick 100: 1000 + // tick 110: 1000 + 2000 = 3000 + // tick 120: 3000 + 3000 = 6000 + + // Step 2: Offset calculation at activeTick (110) + // rawAtActive = 3000 + // offset = 5000 - 3000 = 2000 + + // Step 3: Anchored liquidity + // tick 100: 1000 + 2000 = 3000 + // tick 110: 3000 + 2000 = 5000 + // tick 120: 6000 + 2000 = 8000 + + expect(result.get(100)?.toString()).toBe('3000') + expect(result.get(110)?.toString()).toBe('5000') + expect(result.get(120)?.toString()).toBe('8000') + }) + + it('should handle negative liquidityNet values', () => { + const ticksProcessed: TickProcessed[] = [ + { + tick: 100, + liquidityNet: JSBI.BigInt(5000), + liquidityActive: JSBI.BigInt(0), + } as TickProcessed, + { + tick: 110, + liquidityNet: JSBI.BigInt(-2000), + liquidityActive: JSBI.BigInt(0), + } as TickProcessed, + { + tick: 120, + liquidityNet: JSBI.BigInt(1000), + liquidityActive: JSBI.BigInt(0), + } as TickProcessed, + ] + + const activeTick = 110 + const liquidity = JSBI.BigInt(10000) + + const result = calculateAnchoredLiquidityByTick({ ticksProcessed, activeTick, liquidity }) + + // Cumulative sum: + // tick 100: 5000 + // tick 110: 5000 + (-2000) = 3000 + // tick 120: 3000 + 1000 = 4000 + + // Offset: 10000 - 3000 = 7000 + + // Anchored: + // tick 100: 5000 + 7000 = 12000 + // tick 110: 3000 + 7000 = 10000 + // tick 120: 4000 + 7000 = 11000 + + expect(result.get(100)?.toString()).toBe('12000') + expect(result.get(110)?.toString()).toBe('10000') + expect(result.get(120)?.toString()).toBe('11000') + }) + + it('should handle single tick', () => { + const ticksProcessed: TickProcessed[] = [ + { + tick: 100, + liquidityNet: JSBI.BigInt(1000), + liquidityActive: JSBI.BigInt(0), + } as TickProcessed, + ] + + const activeTick = 100 + const liquidity = JSBI.BigInt(5000) + + const result = calculateAnchoredLiquidityByTick({ ticksProcessed, activeTick, liquidity }) + + // Cumulative sum: tick 100 = 1000 + // Offset: 5000 - 1000 = 4000 + // Anchored: 1000 + 4000 = 5000 + + expect(result.get(100)?.toString()).toBe('5000') + expect(result.size).toBe(1) + }) + + it('should return empty map for empty ticks array', () => { + const ticksProcessed: TickProcessed[] = [] + const activeTick = 100 + const liquidity = JSBI.BigInt(5000) + + const result = calculateAnchoredLiquidityByTick({ ticksProcessed, activeTick, liquidity }) + + expect(result.size).toBe(0) + }) + + it('should handle activeTick not in the ticks array', () => { + const ticksProcessed: TickProcessed[] = [ + { + tick: 100, + liquidityNet: JSBI.BigInt(1000), + liquidityActive: JSBI.BigInt(0), + } as TickProcessed, + { + tick: 110, + liquidityNet: JSBI.BigInt(2000), + liquidityActive: JSBI.BigInt(0), + } as TickProcessed, + ] + + const activeTick = 105 // Not in the array + const liquidity = JSBI.BigInt(5000) + + const result = calculateAnchoredLiquidityByTick({ ticksProcessed, activeTick, liquidity }) + + // rawAtActive = 0 (not found) + // Offset: 5000 - 0 = 5000 + // tick 100: 1000 + 5000 = 6000 + // tick 110: 3000 + 5000 = 8000 + + expect(result.get(100)?.toString()).toBe('6000') + expect(result.get(110)?.toString()).toBe('8000') + }) + + it('should calculate anchored liquidity for USDC/WETH pool', () => { + // Real data from USDC/WETH pool + const ticksProcessed: TickProcessed[] = [ + { + tick: 194810, + liquidityNet: JSBI.BigInt('1198884537287'), + liquidityActive: JSBI.BigInt('1015788607779785415'), + } as TickProcessed, + { + tick: 194820, + liquidityNet: JSBI.BigInt('-3256731548635412'), + liquidityActive: JSBI.BigInt('1012531876231150003'), + } as TickProcessed, + { + tick: 194830, + liquidityNet: JSBI.BigInt('195665307069921'), + liquidityActive: JSBI.BigInt('1012727541538219924'), + } as TickProcessed, + { + tick: 194840, + liquidityNet: JSBI.BigInt('-1584918636957816'), + liquidityActive: JSBI.BigInt('1011142622901262108'), + } as TickProcessed, + ] + + const activeTick = 194820 + const liquidity = JSBI.BigInt('1012531876231150003') + + const result = calculateAnchoredLiquidityByTick({ ticksProcessed, activeTick, liquidity }) + + // Verify that the anchored liquidity at activeTick matches the pool liquidity + expect(result.get(194820)?.toString()).toBe(liquidity.toString()) + + // Verify all ticks have anchored values + expect(result.size).toBe(4) + expect(result.get(194810)).toBeDefined() + expect(result.get(194820)).toBeDefined() + expect(result.get(194830)).toBeDefined() + expect(result.get(194840)).toBeDefined() + + // Verify the anchored values are JSBI instances + expect(JSBI.greaterThan(result.get(194810)!, JSBI.BigInt(0))).toBe(true) + expect(JSBI.greaterThan(result.get(194830)!, JSBI.BigInt(0))).toBe(true) + }) + + it('should maintain liquidity relationships across ticks', () => { + const ticksProcessed: TickProcessed[] = [ + { + tick: 194810, + liquidityNet: JSBI.BigInt('1198884537287'), + liquidityActive: JSBI.BigInt('1015788607779785415'), + } as TickProcessed, + { + tick: 194820, + liquidityNet: JSBI.BigInt('-3256731548635412'), + liquidityActive: JSBI.BigInt('1012531876231150003'), + } as TickProcessed, + ] + + const activeTick = 194820 + const liquidity = JSBI.BigInt('1012531876231150003') + + const result = calculateAnchoredLiquidityByTick({ ticksProcessed, activeTick, liquidity }) + + const liq194810 = result.get(194810)! + const liq194820 = result.get(194820)! + const netChange = JSBI.BigInt('-3256731548635412') + + // Verify: liq194820 = liq194810 + netChange + const calculated = JSBI.add(liq194810, netChange) + expect(calculated.toString()).toBe(liq194820.toString()) + }) +}) diff --git a/apps/web/src/components/Charts/LiquidityChart/utils/calculateAnchoredLiquidityByTick.ts b/apps/web/src/components/Charts/LiquidityChart/utils/calculateAnchoredLiquidityByTick.ts new file mode 100644 index 00000000000..c7f18324bcd --- /dev/null +++ b/apps/web/src/components/Charts/LiquidityChart/utils/calculateAnchoredLiquidityByTick.ts @@ -0,0 +1,69 @@ +import JSBI from 'jsbi' +import { TickProcessed } from 'utils/computeSurroundingTicks' + +/** + * Computes the active liquidity at each tick boundary by anchoring cumulative liquidityNet + * to the pool's actual liquidity at the current tick. + * + * This three-step process corrects the unanchored cumulative sums from computeSurroundingTicks + * to produce accurate liquidity values for chart bar heights and tooltip token amounts: + * + * 1. Build cumulative sum of liquidityNet across all ticks (relative changes only) + * 2. Calculate offset by comparing cumulative sum at active tick to actual pool.liquidity + * 3. Apply offset to all ticks to get anchored active liquidity values + * + * Example with concrete values: + * - Suppose activeTick = 100, pool.liquidity = 50000 + * - Raw cumulative sum at tick 100: rawCumLByTick[100] = 30000 + * - Offset = 50000 - 30000 = 20000 + * - Anchored liquidity at tick 100: 30000 + 20000 = 50000 ✓ + * - This ensures the chart's bar heights match actual pool state + * + * @param ticksProcessed - Array of processed ticks from computeSurroundingTicks + * @param activeTick - The current active tick of the pool + * @param liquidity - The current pool liquidity (JSBI) + * @returns Map of tick index to anchored active liquidity (JSBI) + */ +export function calculateAnchoredLiquidityByTick({ + ticksProcessed, + activeTick, + liquidity, +}: { + ticksProcessed: TickProcessed[] + activeTick: number + liquidity: JSBI +}): Map { + // Step 1: Calculate cumulative sum of liquidityNet across fetched ticks (raw, unanchored) + // This gives us the relative liquidity changes but not anchored to actual pool state + // i.e tick: liquidityNet { 100: 1000, 101: 2000, 102: 3000 } + // cumulative sum: { 100: 1000, 101: 3000, 102: 6000 } + let runningL = JSBI.BigInt(0) + const rawCumLByTick = new Map() + for (const t of ticksProcessed) { + const idx = t.tick + const net = t.liquidityNet + runningL = JSBI.add(runningL, net) + rawCumLByTick.set(idx, runningL) + } + + // Step 2: Anchor to pool.liquidity at the active tick boundary using an offset + // The offset corrects the raw cumulative sum to match the actual pool liquidity + // i.e. if activeTick = 100: + // cumulative sum at tick 100: 1000 + // pool.liquidity: 5000 + // offset: 5000 - 1000 = 4000 + const rawAtActive = rawCumLByTick.get(activeTick) || 0 + const offset = JSBI.subtract(liquidity, JSBI.BigInt(rawAtActive)) + + // Step 3: Apply offset to calculate anchored active liquidity per tick boundary + // These anchored values are used for both bar heights and tooltip token amounts + // i.e. cumulative sum: { 100: 1000, 101: 3000, 102: 6000 } + // offset: 4000 + // anchored liquidity: { 100: 5000, 101: 7000, 102: 10000 } + const activeLiquidityByTick = new Map() + for (const [idx, rawL] of rawCumLByTick.entries()) { + activeLiquidityByTick.set(idx, JSBI.add(offset, JSBI.BigInt(rawL))) + } + + return activeLiquidityByTick +} diff --git a/apps/web/src/components/Icons/LoadingSpinner.tsx b/apps/web/src/components/Icons/LoadingSpinner.tsx index 3957302aa6d..3c21b598728 100644 --- a/apps/web/src/components/Icons/LoadingSpinner.tsx +++ b/apps/web/src/components/Icons/LoadingSpinner.tsx @@ -1,5 +1,5 @@ -import { useSporeColors } from 'ui/src' -import { StyledRotatingSVG } from '~/components/Icons/shared' +import { StyledRotatingSVG, StyledSVG } from 'components/Icons/shared' +import { useTheme } from 'lib/styled-components' /** * Takes in custom size and stroke for circle color, default to primary color as fill, @@ -16,14 +16,14 @@ export default function Loader({ strokeWidth?: number [k: string]: any }) { - const colors = useSporeColors() + const theme = useTheme() return ( + + + + + + + + ) +} + export function LoaderV3({ size = '16px', color, ...rest }: { size?: string; color?: string; [k: string]: any }) { - const colors = useSporeColors() + const theme = useTheme() return ( ) diff --git a/apps/web/src/components/Logo/DoubleLogo.tsx b/apps/web/src/components/Logo/DoubleLogo.tsx index 05f61ef2d97..907b8905610 100644 --- a/apps/web/src/components/Logo/DoubleLogo.tsx +++ b/apps/web/src/components/Logo/DoubleLogo.tsx @@ -47,7 +47,7 @@ function LogolessPlaceholder({ return ( - {currency?.symbol?.toUpperCase().replace('$', '').replace(/\s+/g, '').slice(0, 3)} + {currency?.symbol?.toUpperCase().replace(/\$/g, '').replace(/\s+/g, '').slice(0, 3)} {showNetworkLogo && ( diff --git a/apps/web/src/components/NavBar/SearchBar/useIsSearchBarVisible.ts b/apps/web/src/components/NavBar/SearchBar/useIsSearchBarVisible.ts index 04c1e157225..27e9d0403f3 100644 --- a/apps/web/src/components/NavBar/SearchBar/useIsSearchBarVisible.ts +++ b/apps/web/src/components/NavBar/SearchBar/useIsSearchBarVisible.ts @@ -1,3 +1,4 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { useMedia } from 'ui/src' export function useIsSearchBarVisible() { diff --git a/apps/web/src/components/Toucan/Auction/BidActivities/BidActivity.tsx b/apps/web/src/components/Toucan/Auction/BidActivities/BidActivity.tsx new file mode 100644 index 00000000000..1b7989772f2 --- /dev/null +++ b/apps/web/src/components/Toucan/Auction/BidActivities/BidActivity.tsx @@ -0,0 +1,59 @@ +import { useAbbreviatedTimeString } from 'components/Table/utils' +import { BidActivity as BidActivityType } from 'components/Toucan/Auction/store/mockData' +import { DisplayMode } from 'components/Toucan/Auction/store/types' +import { useRef } from 'react' +import { Flex, Text, Unicon } from 'ui/src' +import { ONE_SECOND_MS } from 'utilities/src/time/time' + +interface BidActivityProps { + activity: BidActivityType + displayMode?: DisplayMode // TODO | Toucan: make this required once updated to use this +} + +export const BidActivity = ({ activity }: BidActivityProps) => { + // Convert unix timestamp to relative time string (e.g., "1s ago", "2m ago") + const calculatedTimeAgo = useAbbreviatedTimeString(activity.timestamp * ONE_SECOND_MS) + const timeAgoRef = useRef(calculatedTimeAgo) + const timeAgo = timeAgoRef.current + + // TODO | Toucan: Format price based on displayMode + // - DisplayMode.VALUATION: show as "@ 2.5M" or "@ 2.5B" (market cap) + // - DisplayMode.TOKEN_PRICE: show as "@ $2.50" (fiat price per token with user's selected currency) + const formattedPrice = activity.price // Currently showing mock data, needs proper formatting + + return ( + + {/* Left side: Icon + Bid info */} + + + + + {/* TODO | Toucan: Update to use actual BidToken name instead of hardcoded USDC */} + {activity.bidVolume} USDC + + + @ + + + {formattedPrice} + + + + + {/* Right side: Timestamp */} + + + {timeAgo} + + + + ) +} diff --git a/apps/web/src/components/Toucan/Auction/BidDistributionChart/BlockUpdateCountdown.tsx b/apps/web/src/components/Toucan/Auction/BidDistributionChart/BlockUpdateCountdown.tsx new file mode 100644 index 00000000000..de6d7f12122 --- /dev/null +++ b/apps/web/src/components/Toucan/Auction/BidDistributionChart/BlockUpdateCountdown.tsx @@ -0,0 +1,23 @@ +import { useBlockCountdown } from 'hooks/useBlockCountdown' +import { useTranslation } from 'react-i18next' +import { Text } from 'ui/src' +import { EVMUniverseChainId } from 'uniswap/src/features/chains/types' + +interface BlockUpdateCountdownProps { + chainId: EVMUniverseChainId | undefined +} + +export const BlockUpdateCountdown = ({ chainId }: BlockUpdateCountdownProps) => { + const { t } = useTranslation() + const countdown = useBlockCountdown(chainId) + + if (countdown === undefined) { + return null + } + + return ( + + {t('toucan.auction.nextBlockUpdate', { seconds: Math.ceil(countdown) })} + + ) +} diff --git a/apps/web/src/components/Toucan/Auction/BidDistributionChart/dev/CustomizePresetForm.tsx b/apps/web/src/components/Toucan/Auction/BidDistributionChart/dev/CustomizePresetForm.tsx new file mode 100644 index 00000000000..091d29b3862 --- /dev/null +++ b/apps/web/src/components/Toucan/Auction/BidDistributionChart/dev/CustomizePresetForm.tsx @@ -0,0 +1,472 @@ +// TODO: Remove this file once live auction data is implemented +/** biome-ignore-all lint/correctness/useExhaustiveDependencies: dev-only file with intentional dependencies */ +// Form for customizing and generating bid distribution presets + +import { + AUCTION_TOKEN_DECIMALS, + BID_TOKEN_CONFIGS, + CustomPresetParams, + fromHumanReadable, + generatePresetName, + generateRandomBidDistribution, + SavedCustomPreset, + toHumanReadable, + validateTickCount, +} from 'components/Toucan/Auction/BidDistributionChart/dev/customPresets' +import { useCustomPresetsStore } from 'components/Toucan/Auction/BidDistributionChart/dev/useCustomPresetsStore' +import { useMockDataStore } from 'components/Toucan/Auction/store/mocks/useMockDataStore' +import { useCallback, useEffect, useState } from 'react' +import { Button, Flex, Input, SegmentedControl, Text } from 'ui/src' + +interface CustomizePresetFormProps { + onClose: () => void + editingPreset?: SavedCustomPreset | null +} + +export const CustomizePresetForm = ({ onClose, editingPreset }: CustomizePresetFormProps) => { + const savePreset = useCustomPresetsStore((state) => state.savePreset) + const updatePreset = useCustomPresetsStore((state) => state.updatePreset) + const loadCustomPreset = useMockDataStore((state) => state.loadCustomPreset) + + const isEditMode = !!editingPreset + + // Form state + const [bidToken, setBidToken] = useState<'USDC' | 'ETH'>('USDC') + const [tickSizeHuman, setTickSizeHuman] = useState(BID_TOKEN_CONFIGS.USDC.defaultTickSizeHuman) + const [clearingPriceHuman, setClearingPriceHuman] = useState(BID_TOKEN_CONFIGS.USDC.defaultClearingPriceHuman) + const [tickRangeMin, setTickRangeMin] = useState('1') + const [tickRangeMax, setTickRangeMax] = useState('100') + const [tickCount, setTickCount] = useState('20') + const [bidVolumeMinHuman, setBidVolumeMinHuman] = useState('1000') + const [bidVolumeMaxHuman, setBidVolumeMaxHuman] = useState('10000') + const [totalSupply, setTotalSupply] = useState('1000000000') + + // Load editing preset values + useEffect(() => { + if (editingPreset) { + const config = BID_TOKEN_CONFIGS[editingPreset.bidToken] + setBidToken(editingPreset.bidToken) + setTickSizeHuman(toHumanReadable(editingPreset.tickSize, config.decimals)) + setClearingPriceHuman(toHumanReadable(editingPreset.clearingPrice, config.decimals)) + setTickRangeMin(editingPreset.tickRangeMin.toString()) + setTickRangeMax(editingPreset.tickRangeMax.toString()) + setTickCount(editingPreset.tickCount.toString()) + setBidVolumeMinHuman(toHumanReadable(editingPreset.bidVolumeMin, config.decimals)) + setBidVolumeMaxHuman(toHumanReadable(editingPreset.bidVolumeMax, config.decimals)) + // Convert totalSupply from raw (with 18 decimals) to human-readable + setTotalSupply(toHumanReadable(editingPreset.totalSupply, AUCTION_TOKEN_DECIMALS)) + // Note: bidTokenAddress is derived from bidToken config, not loaded separately + } + }, [editingPreset]) + + const [error, setError] = useState('') + + const config = BID_TOKEN_CONFIGS[bidToken] + + // Handle bid token change + const handleBidTokenChange = useCallback((value: string) => { + const newToken = value as 'USDC' | 'ETH' + setBidToken(newToken) + const newConfig = BID_TOKEN_CONFIGS[newToken] + setTickSizeHuman(newConfig.defaultTickSizeHuman) + setClearingPriceHuman(newConfig.defaultClearingPriceHuman) + setError('') + }, []) + + // Validate clearing price on blur + const handleClearingPriceBlur = useCallback(() => { + try { + const clearingPriceRaw = fromHumanReadable(clearingPriceHuman, config.decimals) + if (BigInt(clearingPriceRaw) < 0n) { + setError('Invalid clearing price') + } else { + setError('') + } + } catch { + setError('Invalid clearing price') + } + }, [clearingPriceHuman, config.decimals]) + + // Arrow key handlers for numeric inputs + const handleTickSizeKeyPress = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (e: any) => { + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault() + const current = parseFloat(tickSizeHuman || '0') + const increment = e.key === 'ArrowUp' ? 0.01 : -0.01 + const newValue = Math.max(0.01, current + increment) + setTickSizeHuman(newValue.toFixed(2)) + } + }, + [tickSizeHuman], + ) + + const handleClearingPriceKeyPress = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (e: any) => { + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault() + const clearingPriceRaw = fromHumanReadable(clearingPriceHuman, config.decimals) + const tickSizeRaw = fromHumanReadable(tickSizeHuman, config.decimals) + const tickSizeBigInt = BigInt(tickSizeRaw) + const increment = e.key === 'ArrowUp' ? tickSizeBigInt : -tickSizeBigInt + const newValue = BigInt(clearingPriceRaw) + increment + const newValueHuman = toHumanReadable(newValue.toString(), config.decimals) + setClearingPriceHuman(newValueHuman) + } + }, + [clearingPriceHuman, tickSizeHuman, config.decimals], + ) + + const handleNumericKeyPress = useCallback( + (setter: (value: string) => void, currentValue: string) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (e: any) => { + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault() + const current = parseInt(currentValue || '0') + const increment = e.key === 'ArrowUp' ? 1 : -1 + const newValue = Math.max(0, current + increment) + setter(newValue.toString()) + } + }, + [], + ) + + // Validate and save preset + const handleSave = useCallback(() => { + try { + setError('') + + // Parse inputs + const tickSizeRaw = fromHumanReadable(tickSizeHuman, config.decimals) + const clearingPriceRaw = fromHumanReadable(clearingPriceHuman, config.decimals) + const rangeMin = parseInt(tickRangeMin) + const rangeMax = parseInt(tickRangeMax) + const count = parseInt(tickCount) + const volumeMinRaw = fromHumanReadable(bidVolumeMinHuman, config.decimals) + const volumeMaxRaw = fromHumanReadable(bidVolumeMaxHuman, config.decimals) + + // Validation + if (rangeMin < 1 || rangeMax > 40000 || rangeMin >= rangeMax) { + setError('Invalid tick range. Min must be 1-40000 and less than max.') + return + } + + if (!validateTickCount({ tickCount: count, tickRangeMin: rangeMin, tickRangeMax: rangeMax })) { + setError(`Tick count must be between 1 and ${rangeMax - rangeMin + 1}`) + return + } + + if (BigInt(volumeMinRaw) >= BigInt(volumeMaxRaw)) { + setError('Bid volume min must be less than max') + return + } + + // Generate preset parameters + // Convert totalSupply to raw format (with 18 decimals) + const totalSupplyRaw = fromHumanReadable(totalSupply, AUCTION_TOKEN_DECIMALS) + + const params: CustomPresetParams = { + bidToken, + bidTokenAddress: config.address, // Store actual token address + tickSize: tickSizeRaw, + clearingPrice: clearingPriceRaw, + tickRangeMin: rangeMin, + tickRangeMax: rangeMax, + tickCount: count, + bidVolumeMin: volumeMinRaw, + bidVolumeMax: volumeMaxRaw, + totalSupply: totalSupplyRaw, + } + + // Generate distribution data + const distributionData = generateRandomBidDistribution(params) + const name = generatePresetName(params) + + if (isEditMode) { + // Update existing preset + updatePreset(editingPreset.id, { + ...params, + name, + distributionData, + }) + + // Load the updated preset + const updatedPreset = { + ...params, + name, + distributionData, + id: editingPreset.id, + createdAt: editingPreset.createdAt, + } + loadCustomPreset(updatedPreset) + } else { + // Save new preset + savePreset({ + ...params, + name, + distributionData, + }) + + // Load the preset (need to create a temporary one with id for loading) + const tempPreset = { + ...params, + name, + distributionData, + id: 'temp', // Will be updated when we select from saved list + createdAt: Date.now(), + } + loadCustomPreset(tempPreset) + } + + onClose() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to generate preset') + } + }, [ + bidToken, + tickSizeHuman, + clearingPriceHuman, + tickRangeMin, + tickRangeMax, + tickCount, + bidVolumeMinHuman, + bidVolumeMaxHuman, + config.decimals, + savePreset, + loadCustomPreset, + onClose, + ]) + + return ( + + {/* Bid Token Selector */} + + + Bid Token + + USDC }, + { value: 'ETH', display: ETH }, + ]} + selectedOption={bidToken} + onSelectOption={handleBidTokenChange} + /> + + + {/* Tick Size and Clearing Price - Same Row */} + + + + Tick Size ({bidToken}) + + + + + + + + + Clearing Price ({bidToken}) + + + + Can be any price + + + + + {/* Tick Range - Same Row */} + + + + Tick Range (multipliers: 1-40000) + + + + + to + + + + + + + + + + Ticks with Bids + + + + Max: {Math.max(0, parseInt(tickRangeMax || '0') - parseInt(tickRangeMin || '0') + 1)} + + + + + {/* Bid Volume Range and Total Supply - Same Row */} + + + + Bid Volume Range (per tick, in {bidToken}) + + + + + to + + + + + + + + + + Total Supply + + + + + + {/* Error Message */} + {error && ( + + {error} + + )} + + {/* Preview */} + + + Preview: + + + {tickCount} random ticks between $ + {toHumanReadable( + (BigInt(fromHumanReadable(tickSizeHuman, config.decimals)) * BigInt(tickRangeMin || '1')).toString(), + config.decimals, + )}{' '} + - $ + {toHumanReadable( + (BigInt(fromHumanReadable(tickSizeHuman, config.decimals)) * BigInt(tickRangeMax || '100')).toString(), + config.decimals, + )} + + + Volume per tick: ${bidVolumeMinHuman} - ${bidVolumeMaxHuman} + + + + {/* Save/Update Button */} + + + ) +} diff --git a/apps/web/src/components/Toucan/Auction/BidDistributionChart/dev/MockDataSelectorModal.tsx b/apps/web/src/components/Toucan/Auction/BidDistributionChart/dev/MockDataSelectorModal.tsx new file mode 100644 index 00000000000..9fbfaf2c468 --- /dev/null +++ b/apps/web/src/components/Toucan/Auction/BidDistributionChart/dev/MockDataSelectorModal.tsx @@ -0,0 +1,139 @@ +// TODO: Remove this file once live auction data is implemented +// Modal for selecting mock bid distribution data in development + +import { CustomizePresetForm } from 'components/Toucan/Auction/BidDistributionChart/dev/CustomizePresetForm' +import { SavedCustomPreset } from 'components/Toucan/Auction/BidDistributionChart/dev/customPresets' +import { getDatasetLabel } from 'components/Toucan/Auction/BidDistributionChart/dev/devUtils' +import { SavedPresetsList } from 'components/Toucan/Auction/BidDistributionChart/dev/SavedPresetsList' +import { MOCK_BID_DISTRIBUTION_DATASETS } from 'components/Toucan/Auction/store/mocks/distributionData/bidDistributionMockData' +import { useMockDataStore } from 'components/Toucan/Auction/store/mocks/useMockDataStore' +import { BidTokenInfo } from 'components/Toucan/Auction/store/types' +import { useMemo, useState } from 'react' +import { Flex, SegmentedControl, Text, TouchableArea } from 'ui/src' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +type TabType = 'quick' | 'customize' | 'saved' + +interface MockDataSelectorModalProps { + bidTokenInfo: BidTokenInfo +} + +export const MockDataSelectorModal = ({ bidTokenInfo }: MockDataSelectorModalProps) => { + // Quick select datasets are all USDC-based, so use hardcoded USDC info for labels + // This prevents display issues when an ETH preset is selected as active + const quickSelectBidTokenInfo: BidTokenInfo = { + symbol: 'USDC', + decimals: 6, + priceFiat: 1, + } + const [isOpen, setIsOpen] = useState(false) + const [activeTab, setActiveTab] = useState('quick') + const [editingPreset, setEditingPreset] = useState(null) + const { selectedDatasetIndex, setSelectedDatasetIndex } = useMockDataStore() + + const datasetLabels = useMemo(() => { + return MOCK_BID_DISTRIBUTION_DATASETS.map((dataset) => getDatasetLabel(dataset, quickSelectBidTokenInfo)) + }, []) + + const handleSelectDataset = (index: number) => { + setSelectedDatasetIndex(index) + setIsOpen(false) + } + + const handleEditPreset = (preset: SavedCustomPreset) => { + setEditingPreset(preset) + setActiveTab('customize') + } + + const handleCloseModal = () => { + setIsOpen(false) + setEditingPreset(null) + // Reset to quick tab when closing + setTimeout(() => setActiveTab('quick'), 300) + } + + const handleCloseCustomizeForm = () => { + setEditingPreset(null) + setIsOpen(false) + // Reset to quick tab when closing + setTimeout(() => setActiveTab('quick'), 300) + } + + return ( + <> + {/* TODO | Toucan: Remove this dev button once live */} + setIsOpen(true)}> + + dev + + + + + + + Bid Distribution Data + + + {/* Tab Selector */} + Quick Select }, + { value: 'customize', display: Customize }, + { value: 'saved', display: Saved }, + ]} + selectedOption={activeTab} + onSelectOption={(value) => setActiveTab(value as TabType)} + /> + + {/* Tab Content */} + {activeTab === 'quick' && ( + + {MOCK_BID_DISTRIBUTION_DATASETS.map((dataset, index) => { + const { tickCount, minPrice, maxPrice } = datasetLabels[index] + const isSelected = selectedDatasetIndex === index + + return ( + handleSelectDataset(index)} + backgroundColor={isSelected ? '$surface3' : '$surface2'} + p="$spacing12" + borderRadius="$rounded12" + hoverStyle={{ backgroundColor: '$surface3' }} + > + + + {tickCount} Ticks + + + ${minPrice.toFixed(2)} - ${maxPrice.toFixed(2)} + + + + ) + })} + + )} + + {activeTab === 'customize' && ( + + )} + + {activeTab === 'saved' && } + + + + ) +} diff --git a/apps/web/src/components/Toucan/Auction/BidDistributionChart/dev/SavedPresetsList.tsx b/apps/web/src/components/Toucan/Auction/BidDistributionChart/dev/SavedPresetsList.tsx new file mode 100644 index 00000000000..af2dea9c6e8 --- /dev/null +++ b/apps/web/src/components/Toucan/Auction/BidDistributionChart/dev/SavedPresetsList.tsx @@ -0,0 +1,166 @@ +// TODO: Remove this file once live auction data is implemented +// List of saved custom bid distribution presets + +import { + BID_TOKEN_CONFIGS, + getBidTokenInfoFromConfig, + SavedCustomPreset, + toHumanReadable, +} from 'components/Toucan/Auction/BidDistributionChart/dev/customPresets' +import { getDatasetLabel } from 'components/Toucan/Auction/BidDistributionChart/dev/devUtils' +import { useCustomPresetsStore } from 'components/Toucan/Auction/BidDistributionChart/dev/useCustomPresetsStore' +import { useMockDataStore } from 'components/Toucan/Auction/store/mocks/useMockDataStore' +import { useState } from 'react' +import { Button, Flex, Text, TouchableArea } from 'ui/src' +import { Edit } from 'ui/src/components/icons/Edit' +import { Trash } from 'ui/src/components/icons/Trash' + +interface SavedPresetsListProps { + onClose: () => void + onEditPreset: (preset: SavedCustomPreset) => void +} + +export const SavedPresetsList = ({ onClose, onEditPreset }: SavedPresetsListProps) => { + const { presets, deletePreset, clearAllPresets } = useCustomPresetsStore() + const { loadCustomPreset, selectedPresetId } = useMockDataStore() + const [showConfirmClear, setShowConfirmClear] = useState(false) + + const handleLoadPreset = (presetId: string) => { + const preset = presets.find((p) => p.id === presetId) + if (preset) { + loadCustomPreset(preset) + onClose() + } + } + + const handleClearAll = () => { + if (showConfirmClear) { + clearAllPresets() + setShowConfirmClear(false) + } else { + setShowConfirmClear(true) + } + } + + // Empty state + if (presets.length === 0) { + return ( + + + No custom presets saved. + + + Create one in the Customize tab. + + + ) + } + + return ( + + + Saved Presets + + + + {presets.map((preset) => { + // Create preset-specific bidTokenInfo using its own config (not the active chart's token) + const presetBidTokenInfo = getBidTokenInfoFromConfig(preset.bidToken) + const label = getDatasetLabel(preset.distributionData, presetBidTokenInfo) + const config = BID_TOKEN_CONFIGS[preset.bidToken] + const isSelected = selectedPresetId === preset.id + + // Calculate volume range and clearing price for display + const minVolumeHuman = toHumanReadable(preset.bidVolumeMin, config.decimals) + const maxVolumeHuman = toHumanReadable(preset.bidVolumeMax, config.decimals) + const clearingPriceHuman = toHumanReadable(preset.clearingPrice, config.decimals) + + return ( + + handleLoadPreset(preset.id)} hoverStyle={{ opacity: 0.8 }}> + + + + {label.tickCount} Ticks ({config.symbol}) + + + ${label.minPrice.toFixed(2)} - ${label.maxPrice.toFixed(2)} + + + + {preset.name} + + + Volume per tick: ${minVolumeHuman} - ${maxVolumeHuman} + + + Clearing Price: ${clearingPriceHuman} + + + + + + onEditPreset(preset)} + p="$spacing8" + hoverStyle={{ backgroundColor: '$surface3' }} + borderRadius="$rounded8" + > + + + + deletePreset(preset.id)} + p="$spacing8" + hoverStyle={{ backgroundColor: '$surface3' }} + borderRadius="$rounded8" + > + + + + + ) + })} + + + {/* Clear All Button */} + + {showConfirmClear ? ( + + + Are you sure you want to delete all presets? + + + + + + + ) : ( + + )} + + + ) +} diff --git a/apps/web/src/components/Toucan/Auction/BidDistributionChart/dev/customPresets.ts b/apps/web/src/components/Toucan/Auction/BidDistributionChart/dev/customPresets.ts new file mode 100644 index 00000000000..a1e321809ac --- /dev/null +++ b/apps/web/src/components/Toucan/Auction/BidDistributionChart/dev/customPresets.ts @@ -0,0 +1,270 @@ +// TODO: Remove this file once live auction data is implemented +// Utilities for generating custom bid distribution presets + +import { BidDistributionData } from 'components/Toucan/Auction/store/types' + +// Auction token always has 18 decimals (matches FAKE_AUCTION_DATA.tokenDecimals) +export const AUCTION_TOKEN_DECIMALS = 18 + +// eslint-disable-next-line import/no-unused-modules -- Exported for type safety +export interface BidTokenConfig { + address: string + symbol: 'USDC' | 'ETH' + decimals: number + defaultTickSize: string // raw value (e.g., "500000" for 0.50 USDC) + defaultTickSizeHuman: string // human readable (e.g., "0.50") + defaultClearingPrice: string // raw value + defaultClearingPriceHuman: string // human readable +} + +export const BID_TOKEN_CONFIGS: Record<'USDC' | 'ETH', BidTokenConfig> = { + USDC: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + decimals: 6, + defaultTickSize: '500000', // 0.50 USDC + defaultTickSizeHuman: '0.50', + defaultClearingPrice: '5000000', // 5.00 USDC + defaultClearingPriceHuman: '5.00', + }, + ETH: { + address: '0x0000000000000000000000000000000000000000', // native ETH + symbol: 'ETH', + decimals: 18, + defaultTickSize: '100000000000000', // 0.0001 ETH + defaultTickSizeHuman: '0.0001', + defaultClearingPrice: '1000000000000000', // 0.001 ETH + defaultClearingPriceHuman: '0.001', + }, +} + +export interface CustomPresetParams { + bidToken: 'USDC' | 'ETH' + bidTokenAddress: string // actual token address (needed for useBidTokenInfo) + tickSize: string // raw value + clearingPrice: string // raw value + tickRangeMin: number // multiplier (e.g., 1 means 1x tickSize) + tickRangeMax: number // multiplier + tickCount: number + bidVolumeMin: string // raw value in bid token smallest units + bidVolumeMax: string // raw value in bid token smallest units + totalSupply: string // total supply of auction token +} + +export interface SavedCustomPreset extends CustomPresetParams { + id: string + name: string + createdAt: number + distributionData: BidDistributionData +} + +/** + * Generates random bid distribution data based on preset parameters + * Uses BigInt for precision-safe calculations + * Distribution is weighted to cluster near and above clearing price (like an order book) + */ +export function generateRandomBidDistribution(params: CustomPresetParams): BidDistributionData { + const { tickSize, clearingPrice, tickRangeMin, tickRangeMax, tickCount, bidVolumeMin, bidVolumeMax } = params + + // Calculate available ticks in the range + const availableTicks = tickRangeMax - tickRangeMin + 1 + + if (tickCount > availableTicks) { + throw new Error(`Tick count (${tickCount}) cannot exceed available range (${availableTicks})`) + } + + if (tickCount < 1) { + throw new Error('Tick count must be at least 1') + } + + const tickSizeBigInt = BigInt(tickSize) + const clearingPriceBigInt = BigInt(clearingPrice) + const volumeMinBigInt = BigInt(bidVolumeMin) + const volumeMaxBigInt = BigInt(bidVolumeMax) + const volumeRange = volumeMaxBigInt - volumeMinBigInt + + // Calculate which tick multiplier the clearing price falls at/near + const clearingMultiplier = Number(clearingPriceBigInt / tickSizeBigInt) + + // Generate random unique tick multipliers with clustering near clearing price + const tickMultipliers = new Set() + while (tickMultipliers.size < tickCount) { + let randomMultiplier: number + + // Decide if this should be below or at/above clearing price + // 70% of ticks should be at or above clearing price for realistic orderbook look + const isAboveOrAtClearing = Math.random() > 0.3 + + if (isAboveOrAtClearing) { + // Generate ticks at or above clearing price with tight clustering + // Use exponential distribution for realistic orderbook clustering + const maxDistance = tickRangeMax - clearingMultiplier + if (maxDistance > 0) { + // Very tight clustering: most ticks immediately near clearing + // Allow rare outliers (2% chance) + const isOutlier = Math.random() < 0.02 + let distance: number + + if (isOutlier) { + // Rare outlier: randomly distributed in range + distance = Math.floor(Math.random() * maxDistance) + } else { + // Normal: very tightly clustered near clearing price + // Use exponential with high lambda for tight clustering + const lambda = 8.0 // Higher = tighter clustering + const uniformRandom = Math.random() + const exponentialRandom = -Math.log(1 - uniformRandom) / lambda + // Scale to a smaller portion of maxDistance for tighter clustering + // Most ticks will be within first 10-20% of range + distance = Math.floor(Math.min(exponentialRandom, 0.3) * maxDistance) + } + + randomMultiplier = Math.min(tickRangeMax, Math.floor(clearingMultiplier + distance)) + } else { + randomMultiplier = Math.floor(clearingMultiplier) + } + } else { + // Generate ticks below clearing price with uniform random distribution + const minDistance = clearingMultiplier - tickRangeMin + if (minDistance > 0) { + const distance = Math.floor(Math.random() * minDistance) + randomMultiplier = Math.max(tickRangeMin, Math.floor(clearingMultiplier - distance) - 1) + } else { + randomMultiplier = tickRangeMin + } + } + + // Ensure within valid range + randomMultiplier = Math.max(tickRangeMin, Math.min(tickRangeMax, randomMultiplier)) + tickMultipliers.add(randomMultiplier) + } + + // Generate distribution data with volume that decreases with distance from clearing price + const distributionData = new Map() + + for (const multiplier of tickMultipliers) { + const tickValue = tickSizeBigInt * BigInt(multiplier) + const tickValueBigInt = BigInt(tickValue) + + // Calculate distance from clearing price for volume weighting + const distanceFromClearing = Math.abs(Number(tickValueBigInt - clearingPriceBigInt) / Number(tickSizeBigInt)) + + // For bids at or above clearing: volume decreases with distance (orderbook style) + // For bids below clearing: more random volume + let randomVolume: bigint + + if (tickValueBigInt >= clearingPriceBigInt) { + // At or above clearing: higher volume near clearing, exponentially decreasing + // Base volume factor: 1.0 at clearing, decreasing to ~0.3 at far distances + const distanceFactor = Math.exp(-distanceFromClearing * 0.05) + // Add some randomness (±30%) while maintaining general trend + const randomFactor = 0.7 + Math.random() * 0.6 // 0.7 to 1.3 + + const volumeFactor = distanceFactor * randomFactor + const scaledRange = Number(volumeRange) * volumeFactor + const randomOffset = BigInt(Math.floor(scaledRange * Math.random())) + const baseVolume = volumeMinBigInt + randomOffset + + // Ensure volume stays within bounds + randomVolume = baseVolume > volumeMaxBigInt ? volumeMaxBigInt : baseVolume + } else { + // Below clearing: use more random distribution + const randomRatio = Math.random() + const randomOffset = BigInt(Math.floor(Number(volumeRange) * randomRatio)) + randomVolume = volumeMinBigInt + randomOffset + } + + distributionData.set(tickValue.toString(), randomVolume.toString()) + } + + return distributionData +} + +/** + * Converts raw token value to human-readable decimal format + * @example toHumanReadable("500000", 6) // "0.5" + * @example toHumanReadable("100000000000000", 18) // "0.0001" + */ +export function toHumanReadable(value: string, decimals: number): string { + try { + const valueBigInt = BigInt(value) + const divisor = BigInt(Math.pow(10, decimals)) + + const wholePart = valueBigInt / divisor + const fractionalPart = valueBigInt % divisor + + if (fractionalPart === 0n) { + return wholePart.toString() + } + + const fractionalStr = fractionalPart.toString().padStart(decimals, '0') + // Remove trailing zeros + const trimmed = fractionalStr.replace(/0+$/, '') + + return `${wholePart}.${trimmed}` + } catch { + return '0' + } +} + +/** + * Converts human-readable decimal to raw token value + * @example fromHumanReadable("0.5", 6) // "500000" + * @example fromHumanReadable("0.0001", 18) // "100000000000000" + */ +export function fromHumanReadable(value: string, decimals: number): string { + try { + // Handle empty or invalid input + if (!value || value === '' || value === '.') { + return '0' + } + + const [whole = '0', fractional = ''] = value.split('.') + const paddedFractional = fractional.padEnd(decimals, '0').slice(0, decimals) + const combined = (whole === '' ? '0' : whole) + paddedFractional + return BigInt(combined || '0').toString() + } catch { + return '0' + } +} + +/** + * Validates that tick count doesn't exceed available range + */ +export function validateTickCount(params: { tickCount: number; tickRangeMin: number; tickRangeMax: number }): boolean { + const { tickCount, tickRangeMin, tickRangeMax } = params + const availableTicks = tickRangeMax - tickRangeMin + 1 + return tickCount >= 1 && tickCount <= availableTicks +} + +/** + * Generates a descriptive name for a custom preset + */ +export function generatePresetName(params: CustomPresetParams): string { + const config = BID_TOKEN_CONFIGS[params.bidToken] + const minTick = toHumanReadable((BigInt(params.tickSize) * BigInt(params.tickRangeMin)).toString(), config.decimals) + const maxTick = toHumanReadable((BigInt(params.tickSize) * BigInt(params.tickRangeMax)).toString(), config.decimals) + + return `${params.bidToken} | ${params.tickCount} ticks | $${minTick}-$${maxTick}` +} + +/** + * Creates BidTokenInfo from a bid token config for display purposes + * Uses mock USD prices since this is only for displaying preset labels in the list + * The actual chart rendering uses real prices from useBidTokenInfo + */ +export function getBidTokenInfoFromConfig(bidToken: 'USDC' | 'ETH'): { + symbol: string + decimals: number + priceFiat: number +} { + const config = BID_TOKEN_CONFIGS[bidToken] + // Mock prices for display only - USDC is $1, ETH is $3000 + const mockPriceFiat = bidToken === 'USDC' ? 1 : 3000 + + return { + symbol: config.symbol, + decimals: config.decimals, + priceFiat: mockPriceFiat, + } +} diff --git a/apps/web/src/components/Toucan/Auction/BidDistributionChart/dev/devUtils.ts b/apps/web/src/components/Toucan/Auction/BidDistributionChart/dev/devUtils.ts new file mode 100644 index 00000000000..b635adfe62d --- /dev/null +++ b/apps/web/src/components/Toucan/Auction/BidDistributionChart/dev/devUtils.ts @@ -0,0 +1,59 @@ +// TODO: Remove this file once live auction data is implemented +// Utility functions for dev components + +import { BidDistributionData, BidTokenInfo } from 'components/Toucan/Auction/store/types' + +/** + * Generates a dynamic label for a mock dataset showing tick count and price range + * Converts tick values from smallest units (e.g., micro-USDC) to USD using bid token decimals and price + * Uses BigInt for precision-safe calculations with high-decimal tokens + */ +export function getDatasetLabel( + data: BidDistributionData, + bidTokenInfo: BidTokenInfo, +): { tickCount: number; minPrice: number; maxPrice: number } { + // Ensure data is a Map (handle deserialization edge cases) + let mapData: Map + if (data instanceof Map) { + mapData = data + } else if (Array.isArray(data)) { + mapData = new Map(data as [string, string][]) + } else { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime safety for deserialized data + mapData = new Map(data && typeof data === 'object' ? (Object.entries(data) as [string, string][]) : []) + } + + const tickCount = mapData.size + + // Handle empty data + if (tickCount === 0) { + return { + tickCount: 0, + minPrice: 0, + maxPrice: 0, + } + } + + const tickBigInts = Array.from(mapData.keys()).map((tick) => BigInt(tick)) + const minTickBigInt = tickBigInts.reduce((min, curr) => (curr < min ? curr : min)) + const maxTickBigInt = tickBigInts.reduce((max, curr) => (curr > max ? curr : max)) + + // Convert from smallest units to decimal using BigInt division + // We multiply by a scale factor first to preserve precision during division + // Use the token's decimals as the scale factor to maintain full precision + const SCALE_FACTOR = BigInt(Math.pow(10, bidTokenInfo.decimals)) + const decimalsDiv = SCALE_FACTOR + + const minPriceScaled = (minTickBigInt * SCALE_FACTOR) / decimalsDiv + const maxPriceScaled = (maxTickBigInt * SCALE_FACTOR) / decimalsDiv + + // Convert to USD (now safe to convert to Number since we're dealing with reasonable display values) + const minPrice = (Number(minPriceScaled) / Number(SCALE_FACTOR)) * bidTokenInfo.priceFiat + const maxPrice = (Number(maxPriceScaled) / Number(SCALE_FACTOR)) * bidTokenInfo.priceFiat + + return { + tickCount, + minPrice, + maxPrice, + } +} diff --git a/apps/web/src/components/Toucan/Auction/BidDistributionChart/dev/useCustomPresetsStore.ts b/apps/web/src/components/Toucan/Auction/BidDistributionChart/dev/useCustomPresetsStore.ts new file mode 100644 index 00000000000..de6b8ecc693 --- /dev/null +++ b/apps/web/src/components/Toucan/Auction/BidDistributionChart/dev/useCustomPresetsStore.ts @@ -0,0 +1,131 @@ +// TODO: Remove this file once live auction data is implemented +// Zustand store for persisting custom bid distribution presets to localStorage + +import { SavedCustomPreset } from 'components/Toucan/Auction/BidDistributionChart/dev/customPresets' +import { create } from 'zustand' +import { PersistStorage, persist, StorageValue } from 'zustand/middleware' + +interface CustomPresetsState { + presets: SavedCustomPreset[] + savePreset: (preset: Omit) => void + updatePreset: (id: string, preset: Omit) => void + deletePreset: (id: string) => void + clearAllPresets: () => void +} + +// Custom storage implementation that handles Map serialization +const customStorage: PersistStorage = { + getItem: (name) => { + const str = localStorage.getItem(name) + if (!str) { + return null + } + const parsed = JSON.parse(str) + if (parsed.state?.presets) { + parsed.state.presets = parsed.state.presets + .map((preset: any) => { + let distributionData: Map + + if (preset.distributionData instanceof Map) { + distributionData = preset.distributionData + } else if (Array.isArray(preset.distributionData)) { + distributionData = new Map(preset.distributionData as [string, string][]) + } else if (preset.distributionData && typeof preset.distributionData === 'object') { + distributionData = new Map(Object.entries(preset.distributionData) as [string, string][]) + } else { + distributionData = new Map() + } + + return { ...preset, distributionData } + }) + .filter((preset: SavedCustomPreset) => preset.distributionData.size > 0) + } + return parsed as StorageValue + }, + setItem: (name, value) => { + const serializable = { + ...value, + state: { + ...value.state, + presets: value.state.presets.map((preset: any) => { + // Convert Map to array for JSON serialization + let distributionData: any + if (preset.distributionData instanceof Map) { + distributionData = Array.from(preset.distributionData.entries()) + } else if ( + preset.distributionData && + typeof preset.distributionData === 'object' && + 'entries' in preset.distributionData && + typeof preset.distributionData.entries === 'function' + ) { + // Map-like object with entries method + distributionData = Array.from(preset.distributionData.entries()) + } else if (Array.isArray(preset.distributionData)) { + distributionData = preset.distributionData + } else if (preset.distributionData && typeof preset.distributionData === 'object') { + // Plain object - convert to array format + distributionData = Object.entries(preset.distributionData) + } else { + distributionData = [] + } + + return { + ...preset, + distributionData, + } + }), + }, + } + localStorage.setItem(name, JSON.stringify(serializable)) + }, + removeItem: (name) => { + localStorage.removeItem(name) + }, +} + +export const useCustomPresetsStore = create()( + persist( + (set) => ({ + presets: [], + + savePreset: (preset) => { + const newPreset: SavedCustomPreset = { + ...preset, + id: crypto.randomUUID(), + createdAt: Date.now(), + } + set((state) => ({ + presets: [...state.presets, newPreset], + })) + }, + + updatePreset: (id, preset) => { + set((state) => ({ + presets: state.presets.map((p) => + p.id === id + ? { + ...preset, + id: p.id, // Keep the same ID + createdAt: p.createdAt, // Keep the original creation date + } + : p, + ), + })) + }, + + deletePreset: (id) => { + set((state) => ({ + presets: state.presets.filter((p) => p.id !== id), + })) + }, + + clearAllPresets: () => { + set({ presets: [] }) + }, + }), + { + name: 'toucan-custom-bid-presets', + storage: customStorage, + }, + ), +) diff --git a/apps/web/src/components/Toucan/Auction/BidDistributionChart/hooks/useChartTooltip.ts b/apps/web/src/components/Toucan/Auction/BidDistributionChart/hooks/useChartTooltip.ts new file mode 100644 index 00000000000..7a81d28f279 --- /dev/null +++ b/apps/web/src/components/Toucan/Auction/BidDistributionChart/hooks/useChartTooltip.ts @@ -0,0 +1,72 @@ +import { TOOLTIP_CONFIG } from 'components/Toucan/Auction/BidDistributionChart/constants' +import { formatTickForDisplay } from 'components/Toucan/Auction/BidDistributionChart/utils/utils' +import { BidTokenInfo, DisplayMode } from 'components/Toucan/Auction/store/types' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { UseSporeColorsReturn } from 'ui/src/hooks/useSporeColors' +import { zIndexes } from 'ui/src/theme' + +interface UseChartTooltipParams { + displayMode: DisplayMode + bidTokenInfo: BidTokenInfo + totalSupply?: string + auctionTokenDecimals: number + formatter: (amount: number) => string + volumeFormatter: (amount: number) => string + colors: UseSporeColorsReturn +} + +/** + * Manages tooltip style + text that shows when user hovers over chart bar + */ +export function useChartTooltip(params: UseChartTooltipParams) { + const { displayMode, bidTokenInfo, totalSupply, auctionTokenDecimals, formatter, volumeFormatter, colors } = params + const { t } = useTranslation() + + const fdvText = t('stats.fdv') + + const createTooltipElement = useCallback((): HTMLDivElement => { + const tooltip = document.createElement('div') + Object.assign(tooltip.style, { + position: 'absolute', + pointerEvents: 'none', + background: colors.surface2.val, + color: colors.neutral1.val, + zIndex: String(zIndexes.tooltip), + fontSize: `${TOOLTIP_CONFIG.FONT_SIZE}px`, + padding: TOOLTIP_CONFIG.PADDING, + borderRadius: TOOLTIP_CONFIG.BORDER_RADIUS, + transform: `translate(-50%, -${TOOLTIP_CONFIG.VERTICAL_OFFSET_PERCENT}%)`, + whiteSpace: 'nowrap', + display: 'none', + }) + return tooltip + }, [colors.neutral1.val, colors.surface2.val]) + + /** + * Formats tooltip text based on display mode, tick value, and volume amount + */ + const formatTooltipText = useCallback( + (tickValue: number, volumeAmount: number): string => { + const tickDisplay = formatTickForDisplay({ + tickValue, + displayMode, + bidTokenInfo, + totalSupply, + auctionTokenDecimals, + formatter, + }) + + // Handle zero values explicitly to show "0" instead of "-" + const volumeDisplay = volumeAmount === 0 ? '0' : volumeFormatter(volumeAmount) + + // Add "FDV" suffix when in valuation mode + const suffix = displayMode === DisplayMode.VALUATION ? ` ${fdvText}` : '' + + return `${volumeDisplay} @ ${tickDisplay}${suffix}` + }, + [displayMode, bidTokenInfo, totalSupply, auctionTokenDecimals, formatter, volumeFormatter, fdvText], + ) + + return { createTooltipElement, formatTooltipText } +} diff --git a/apps/web/src/components/Toucan/Auction/BidDistributionChart/utils/clearingPrice/label.ts b/apps/web/src/components/Toucan/Auction/BidDistributionChart/utils/clearingPrice/label.ts new file mode 100644 index 00000000000..38b1b345eae --- /dev/null +++ b/apps/web/src/components/Toucan/Auction/BidDistributionChart/utils/clearingPrice/label.ts @@ -0,0 +1,61 @@ +import { formatTickForDisplay } from 'components/Toucan/Auction/BidDistributionChart/utils/utils' +import { BidTokenInfo, DisplayMode } from 'components/Toucan/Auction/store/types' + +/** + * Parameters for formatting a clearing price label + */ +interface FormatClearingPriceLabelParams { + clearingPrice: number // Clearing price in decimal form + displayMode: DisplayMode // Current display mode (token price vs valuation) + bidTokenInfo: BidTokenInfo // Token information for formatting + totalSupply?: string // Total supply for valuation calculations + auctionTokenDecimals: number // Decimals for the auction token + formatter: (amount: number) => string // Number formatter function +} + +/** + * Formats a clearing price value for display on the chart label. + * + * This pure function creates a formatted label string that's consistent with + * the chart's tick labels and display mode. It's decoupled from the tooltip + * formatting logic, making it independently testable and reusable. + * + * @param params - Formatting parameters + * @returns Formatted clearing price string + * + * @example + * ```typescript + * // Token price mode + * formatClearingPriceLabel({ + * clearingPrice: 1.5, + * displayMode: DisplayMode.TOKEN_PRICE, + * bidTokenInfo: { decimals: 6, ... }, + * formatter: (n) => `$${n.toFixed(2)}` + * }) + * // Returns: "$1.50" + * + * // Valuation mode with FDV suffix + * formatClearingPriceLabel({ + * clearingPrice: 1500000, + * displayMode: DisplayMode.VALUATION, + * totalSupply: "1000000", + * ... + * }) + * // Returns: "$1.5M FDV" + * ``` + */ +export function formatClearingPriceLabel(params: FormatClearingPriceLabelParams): string { + const { clearingPrice, displayMode, bidTokenInfo, totalSupply, auctionTokenDecimals, formatter } = params + + // Use the same formatter as chart tick labels for consistency + const formattedValue = formatTickForDisplay({ + tickValue: clearingPrice, + displayMode, + bidTokenInfo, + totalSupply, + auctionTokenDecimals, + formatter, + }) + + return formattedValue +} diff --git a/apps/web/src/components/Toucan/Auction/store/mocks/distributionData/100_Ticks.ts b/apps/web/src/components/Toucan/Auction/store/mocks/distributionData/100_Ticks.ts new file mode 100644 index 00000000000..1a4dc6287c1 --- /dev/null +++ b/apps/web/src/components/Toucan/Auction/store/mocks/distributionData/100_Ticks.ts @@ -0,0 +1,111 @@ +import { FAKE_AUCTION_DATA } from 'components/Toucan/Auction/store/mockData' +import { BidDistributionData } from 'components/Toucan/Auction/store/types' + +const TICK_SIZE = FAKE_AUCTION_DATA.tickSize + +// 100 ticks - clearing price at $5.00 +// Realistic orderbook: very tight clustering above clearing price with exponential volume decay +export const MOCK_BID_DISTRIBUTION_DATA_100_TICKS: BidDistributionData = new Map([ + // Below clearing price - random distribution (30 ticks) + [(Number(TICK_SIZE) * 2).toString(), '150000000'], // $1.00 + [(Number(TICK_SIZE) * 3).toString(), '120000000'], // $1.50 + [(Number(TICK_SIZE) * 4).toString(), '180000000'], // $2.00 + [(Number(TICK_SIZE) * 5).toString(), '95000000'], // $2.50 + [(Number(TICK_SIZE) * 6).toString(), '140000000'], // $3.00 + [(Number(TICK_SIZE) * 7).toString(), '110000000'], // $3.50 + [(Number(TICK_SIZE) * 8).toString(), '160000000'], // $4.00 + [(Number(TICK_SIZE) * 9).toString(), '130000000'], // $4.50 + // At clearing price - highest volume + [(Number(TICK_SIZE) * 10).toString(), '2600000000'], // $5.00 - clearing price + // Above clearing - very tight clustering (70 ticks concentrated near clearing) + [(Number(TICK_SIZE) * 11).toString(), '2550000000'], // $5.50 + [(Number(TICK_SIZE) * 12).toString(), '2520000000'], // $6.00 + [(Number(TICK_SIZE) * 13).toString(), '2500000000'], // $6.50 + [(Number(TICK_SIZE) * 14).toString(), '2480000000'], // $7.00 + [(Number(TICK_SIZE) * 15).toString(), '2460000000'], // $7.50 + [(Number(TICK_SIZE) * 16).toString(), '2440000000'], // $8.00 + [(Number(TICK_SIZE) * 17).toString(), '2420000000'], // $8.50 + [(Number(TICK_SIZE) * 18).toString(), '2400000000'], // $9.00 + [(Number(TICK_SIZE) * 19).toString(), '2380000000'], // $9.50 + [(Number(TICK_SIZE) * 20).toString(), '2360000000'], // $10.00 + [(Number(TICK_SIZE) * 21).toString(), '2340000000'], // $10.50 + [(Number(TICK_SIZE) * 22).toString(), '2320000000'], // $11.00 + [(Number(TICK_SIZE) * 23).toString(), '2300000000'], // $11.50 + [(Number(TICK_SIZE) * 24).toString(), '2280000000'], // $12.00 + [(Number(TICK_SIZE) * 25).toString(), '2260000000'], // $12.50 + [(Number(TICK_SIZE) * 26).toString(), '2240000000'], // $13.00 + [(Number(TICK_SIZE) * 27).toString(), '2220000000'], // $13.50 + [(Number(TICK_SIZE) * 28).toString(), '2200000000'], // $14.00 + [(Number(TICK_SIZE) * 29).toString(), '2180000000'], // $14.50 + [(Number(TICK_SIZE) * 30).toString(), '2160000000'], // $15.00 + [(Number(TICK_SIZE) * 31).toString(), '2140000000'], // $15.50 + [(Number(TICK_SIZE) * 32).toString(), '2120000000'], // $16.00 + [(Number(TICK_SIZE) * 33).toString(), '2100000000'], // $16.50 + [(Number(TICK_SIZE) * 34).toString(), '2070000000'], // $17.00 + [(Number(TICK_SIZE) * 35).toString(), '2040000000'], // $17.50 + [(Number(TICK_SIZE) * 36).toString(), '2010000000'], // $18.00 + [(Number(TICK_SIZE) * 37).toString(), '1980000000'], // $18.50 + [(Number(TICK_SIZE) * 38).toString(), '1950000000'], // $19.00 + [(Number(TICK_SIZE) * 39).toString(), '1920000000'], // $19.50 + [(Number(TICK_SIZE) * 40).toString(), '1890000000'], // $20.00 + [(Number(TICK_SIZE) * 41).toString(), '1860000000'], // $20.50 + [(Number(TICK_SIZE) * 42).toString(), '1830000000'], // $21.00 + [(Number(TICK_SIZE) * 43).toString(), '1800000000'], // $21.50 + [(Number(TICK_SIZE) * 44).toString(), '1770000000'], // $22.00 + [(Number(TICK_SIZE) * 45).toString(), '1740000000'], // $22.50 + [(Number(TICK_SIZE) * 46).toString(), '1710000000'], // $23.00 + [(Number(TICK_SIZE) * 47).toString(), '1680000000'], // $23.50 + [(Number(TICK_SIZE) * 48).toString(), '1650000000'], // $24.00 + [(Number(TICK_SIZE) * 49).toString(), '1620000000'], // $24.50 + [(Number(TICK_SIZE) * 50).toString(), '1590000000'], // $25.00 + [(Number(TICK_SIZE) * 51).toString(), '1560000000'], // $25.50 + [(Number(TICK_SIZE) * 52).toString(), '1530000000'], // $26.00 + [(Number(TICK_SIZE) * 53).toString(), '1500000000'], // $26.50 + [(Number(TICK_SIZE) * 54).toString(), '1470000000'], // $27.00 + [(Number(TICK_SIZE) * 55).toString(), '1440000000'], // $27.50 + [(Number(TICK_SIZE) * 56).toString(), '1410000000'], // $28.00 + [(Number(TICK_SIZE) * 57).toString(), '1380000000'], // $28.50 + [(Number(TICK_SIZE) * 58).toString(), '1350000000'], // $29.00 + [(Number(TICK_SIZE) * 59).toString(), '1320000000'], // $29.50 + [(Number(TICK_SIZE) * 60).toString(), '1290000000'], // $30.00 + [(Number(TICK_SIZE) * 61).toString(), '1260000000'], // $30.50 + [(Number(TICK_SIZE) * 62).toString(), '1230000000'], // $31.00 + [(Number(TICK_SIZE) * 63).toString(), '1200000000'], // $31.50 + [(Number(TICK_SIZE) * 64).toString(), '1170000000'], // $32.00 + [(Number(TICK_SIZE) * 65).toString(), '1140000000'], // $32.50 + [(Number(TICK_SIZE) * 66).toString(), '1110000000'], // $33.00 + [(Number(TICK_SIZE) * 67).toString(), '1080000000'], // $33.50 + [(Number(TICK_SIZE) * 68).toString(), '1050000000'], // $34.00 + [(Number(TICK_SIZE) * 69).toString(), '1020000000'], // $34.50 + [(Number(TICK_SIZE) * 70).toString(), '990000000'], // $35.00 + [(Number(TICK_SIZE) * 71).toString(), '960000000'], // $35.50 + [(Number(TICK_SIZE) * 72).toString(), '930000000'], // $36.00 + [(Number(TICK_SIZE) * 73).toString(), '900000000'], // $36.50 + [(Number(TICK_SIZE) * 74).toString(), '870000000'], // $37.00 + [(Number(TICK_SIZE) * 75).toString(), '840000000'], // $37.50 + [(Number(TICK_SIZE) * 76).toString(), '810000000'], // $38.00 + [(Number(TICK_SIZE) * 77).toString(), '780000000'], // $38.50 + [(Number(TICK_SIZE) * 78).toString(), '750000000'], // $39.00 + [(Number(TICK_SIZE) * 79).toString(), '720000000'], // $39.50 + [(Number(TICK_SIZE) * 80).toString(), '690000000'], // $40.00 + [(Number(TICK_SIZE) * 82).toString(), '660000000'], // $41.00 + [(Number(TICK_SIZE) * 84).toString(), '630000000'], // $42.00 + [(Number(TICK_SIZE) * 86).toString(), '600000000'], // $43.00 + [(Number(TICK_SIZE) * 88).toString(), '570000000'], // $44.00 + [(Number(TICK_SIZE) * 90).toString(), '540000000'], // $45.00 + [(Number(TICK_SIZE) * 92).toString(), '510000000'], // $46.00 + [(Number(TICK_SIZE) * 94).toString(), '480000000'], // $47.00 + [(Number(TICK_SIZE) * 96).toString(), '450000000'], // $48.00 + [(Number(TICK_SIZE) * 98).toString(), '420000000'], // $49.00 + [(Number(TICK_SIZE) * 100).toString(), '390000000'], // $50.00 + [(Number(TICK_SIZE) * 105).toString(), '350000000'], // $52.50 + [(Number(TICK_SIZE) * 110).toString(), '320000000'], // $55.00 + [(Number(TICK_SIZE) * 115).toString(), '290000000'], // $57.50 + [(Number(TICK_SIZE) * 120).toString(), '260000000'], // $60.00 + // Outliers - rare bids far from clearing + [(Number(TICK_SIZE) * 140).toString(), '220000000'], // $70.00 + [(Number(TICK_SIZE) * 160).toString(), '180000000'], // $80.00 + [(Number(TICK_SIZE) * 200).toString(), '140000000'], // $100.00 + [(Number(TICK_SIZE) * 250).toString(), '100000000'], // $125.00 + [(Number(TICK_SIZE) * 300).toString(), '80000000'], // $150.00 +]) diff --git a/apps/web/src/components/Toucan/Auction/store/mocks/distributionData/10_Ticks.ts b/apps/web/src/components/Toucan/Auction/store/mocks/distributionData/10_Ticks.ts new file mode 100644 index 00000000000..df5463983ee --- /dev/null +++ b/apps/web/src/components/Toucan/Auction/store/mocks/distributionData/10_Ticks.ts @@ -0,0 +1,21 @@ +import { FAKE_AUCTION_DATA } from 'components/Toucan/Auction/store/mockData' +import { BidDistributionData } from 'components/Toucan/Auction/store/types' + +const TICK_SIZE = FAKE_AUCTION_DATA.tickSize + +// 10 ticks clustered near clearing price ($5.00) +// Realistic orderbook: tight clustering above clearing, descending volume with distance +export const MOCK_BID_DISTRIBUTION_DATA_10_TICKS: BidDistributionData = new Map([ + // Below clearing price - random distribution (30%) + [(Number(TICK_SIZE) * 2).toString(), '420000000'], // $1.00 + [(Number(TICK_SIZE) * 6).toString(), '380000000'], // $3.00 + [(Number(TICK_SIZE) * 8).toString(), '550000000'], // $4.00 + // At and above clearing price - tight clustering with descending volume (70%) + [(Number(TICK_SIZE) * 10).toString(), '920000000'], // $5.00 - clearing price (highest volume) + [(Number(TICK_SIZE) * 11).toString(), '880000000'], // $5.50 + [(Number(TICK_SIZE) * 12).toString(), '830000000'], // $6.00 + [(Number(TICK_SIZE) * 13).toString(), '740000000'], // $6.50 + [(Number(TICK_SIZE) * 14).toString(), '650000000'], // $7.00 + [(Number(TICK_SIZE) * 15).toString(), '610000000'], // $7.50 + [(Number(TICK_SIZE) * 18).toString(), '470000000'], // $9.00 - outlier +]) diff --git a/apps/web/src/components/Toucan/Auction/store/mocks/distributionData/20_Ticks.ts b/apps/web/src/components/Toucan/Auction/store/mocks/distributionData/20_Ticks.ts new file mode 100644 index 00000000000..fe31d23532f --- /dev/null +++ b/apps/web/src/components/Toucan/Auction/store/mocks/distributionData/20_Ticks.ts @@ -0,0 +1,31 @@ +import { FAKE_AUCTION_DATA } from 'components/Toucan/Auction/store/mockData' +import { BidDistributionData } from 'components/Toucan/Auction/store/types' + +const TICK_SIZE = FAKE_AUCTION_DATA.tickSize + +// 20 ticks - clearing price at $5.00 +// Realistic orderbook: tight clustering above clearing price with descending volume +export const MOCK_BID_DISTRIBUTION_DATA_20_TICKS: BidDistributionData = new Map([ + // Below clearing price - random distribution (30%) + [(Number(TICK_SIZE) * 2).toString(), '20000000000'], // $1.00 + [(Number(TICK_SIZE) * 4).toString(), '15000000000'], // $2.00 + [(Number(TICK_SIZE) * 6).toString(), '20000000000'], // $3.00 + [(Number(TICK_SIZE) * 7).toString(), '10000000000'], // $3.50 + [(Number(TICK_SIZE) * 8).toString(), '20000000000'], // $4.00 + [(Number(TICK_SIZE) * 9).toString(), '18000000000'], // $4.50 + // At and above clearing price - tight clustering with descending volume (70%) + [(Number(TICK_SIZE) * 10).toString(), '65000000000'], // $5.00 - clearing price (highest volume) + [(Number(TICK_SIZE) * 11).toString(), '50000000000'], // $5.50 + [(Number(TICK_SIZE) * 12).toString(), '50000000000'], // $6.00 + [(Number(TICK_SIZE) * 13).toString(), '45000000000'], // $6.50 + [(Number(TICK_SIZE) * 14).toString(), '45000000000'], // $7.00 + [(Number(TICK_SIZE) * 15).toString(), '40000000000'], // $7.50 + [(Number(TICK_SIZE) * 16).toString(), '35000000000'], // $8.00 + [(Number(TICK_SIZE) * 17).toString(), '30000000000'], // $8.50 + [(Number(TICK_SIZE) * 18).toString(), '25000000000'], // $9.00 + [(Number(TICK_SIZE) * 19).toString(), '20000000000'], // $9.50 + [(Number(TICK_SIZE) * 20).toString(), '15000000000'], // $10.00 + [(Number(TICK_SIZE) * 22).toString(), '10000000000'], // $11.00 + [(Number(TICK_SIZE) * 24).toString(), '5000000000'], // $12.00 + [(Number(TICK_SIZE) * 30).toString(), '1000000000'], // $15.00 - outlier +]) diff --git a/apps/web/src/components/Toucan/Auction/store/mocks/distributionData/50_Ticks.ts b/apps/web/src/components/Toucan/Auction/store/mocks/distributionData/50_Ticks.ts new file mode 100644 index 00000000000..805098a8f6e --- /dev/null +++ b/apps/web/src/components/Toucan/Auction/store/mocks/distributionData/50_Ticks.ts @@ -0,0 +1,62 @@ +import { FAKE_AUCTION_DATA } from 'components/Toucan/Auction/store/mockData' +import { BidDistributionData } from 'components/Toucan/Auction/store/types' + +const TICK_SIZE = FAKE_AUCTION_DATA.tickSize + +// 50 ticks - clearing price at $5.00 +// Realistic orderbook: tight clustering above clearing price with exponential decay in volume +export const MOCK_BID_DISTRIBUTION_DATA_50_TICKS: BidDistributionData = new Map([ + // Below clearing price - random distribution (30%) + [(Number(TICK_SIZE) * 2).toString(), '120000000'], // $1.00 + [(Number(TICK_SIZE) * 3).toString(), '95000000'], // $1.50 + [(Number(TICK_SIZE) * 4).toString(), '110000000'], // $2.00 + [(Number(TICK_SIZE) * 5).toString(), '80000000'], // $2.50 + [(Number(TICK_SIZE) * 6).toString(), '90000000'], // $3.00 + [(Number(TICK_SIZE) * 7).toString(), '85000000'], // $3.50 + [(Number(TICK_SIZE) * 8).toString(), '100000000'], // $4.00 + [(Number(TICK_SIZE) * 9).toString(), '95000000'], // $4.50 + // At clearing price - highest volume + [(Number(TICK_SIZE) * 10).toString(), '2800000000'], // $5.00 - clearing price + // Above clearing price - tight clustering with descending volume + [(Number(TICK_SIZE) * 11).toString(), '2700000000'], // $5.50 + [(Number(TICK_SIZE) * 12).toString(), '2650000000'], // $6.00 + [(Number(TICK_SIZE) * 13).toString(), '2600000000'], // $6.50 + [(Number(TICK_SIZE) * 14).toString(), '2550000000'], // $7.00 + [(Number(TICK_SIZE) * 15).toString(), '2500000000'], // $7.50 + [(Number(TICK_SIZE) * 16).toString(), '2450000000'], // $8.00 + [(Number(TICK_SIZE) * 17).toString(), '2400000000'], // $8.50 + [(Number(TICK_SIZE) * 18).toString(), '2350000000'], // $9.00 + [(Number(TICK_SIZE) * 19).toString(), '2300000000'], // $9.50 + [(Number(TICK_SIZE) * 20).toString(), '2250000000'], // $10.00 + [(Number(TICK_SIZE) * 21).toString(), '2200000000'], // $10.50 + [(Number(TICK_SIZE) * 22).toString(), '2150000000'], // $11.00 + [(Number(TICK_SIZE) * 23).toString(), '2100000000'], // $11.50 + [(Number(TICK_SIZE) * 24).toString(), '2050000000'], // $12.00 + [(Number(TICK_SIZE) * 25).toString(), '1950000000'], // $12.50 + [(Number(TICK_SIZE) * 26).toString(), '1850000000'], // $13.00 + [(Number(TICK_SIZE) * 27).toString(), '1750000000'], // $13.50 + [(Number(TICK_SIZE) * 28).toString(), '1650000000'], // $14.00 + [(Number(TICK_SIZE) * 29).toString(), '1550000000'], // $14.50 + [(Number(TICK_SIZE) * 30).toString(), '1450000000'], // $15.00 + [(Number(TICK_SIZE) * 31).toString(), '1350000000'], // $15.50 + [(Number(TICK_SIZE) * 32).toString(), '1250000000'], // $16.00 + [(Number(TICK_SIZE) * 33).toString(), '1150000000'], // $16.50 + [(Number(TICK_SIZE) * 34).toString(), '1050000000'], // $17.00 + [(Number(TICK_SIZE) * 35).toString(), '950000000'], // $17.50 + [(Number(TICK_SIZE) * 36).toString(), '850000000'], // $18.00 + [(Number(TICK_SIZE) * 37).toString(), '750000000'], // $18.50 + [(Number(TICK_SIZE) * 38).toString(), '650000000'], // $19.00 + [(Number(TICK_SIZE) * 40).toString(), '550000000'], // $20.00 + [(Number(TICK_SIZE) * 42).toString(), '450000000'], // $21.00 + [(Number(TICK_SIZE) * 44).toString(), '380000000'], // $22.00 + [(Number(TICK_SIZE) * 46).toString(), '320000000'], // $23.00 + [(Number(TICK_SIZE) * 48).toString(), '280000000'], // $24.00 + [(Number(TICK_SIZE) * 52).toString(), '240000000'], // $26.00 + [(Number(TICK_SIZE) * 56).toString(), '200000000'], // $28.00 + [(Number(TICK_SIZE) * 60).toString(), '180000000'], // $30.00 + // Outliers + [(Number(TICK_SIZE) * 70).toString(), '150000000'], // $35.00 + [(Number(TICK_SIZE) * 90).toString(), '120000000'], // $45.00 + [(Number(TICK_SIZE) * 120).toString(), '100000000'], // $60.00 + [(Number(TICK_SIZE) * 180).toString(), '80000000'], // $90.00 +]) diff --git a/apps/web/src/components/Toucan/Auction/store/mocks/distributionData/bidDistributionMockData.ts b/apps/web/src/components/Toucan/Auction/store/mocks/distributionData/bidDistributionMockData.ts new file mode 100644 index 00000000000..3151dc49e33 --- /dev/null +++ b/apps/web/src/components/Toucan/Auction/store/mocks/distributionData/bidDistributionMockData.ts @@ -0,0 +1,15 @@ +// TODO | Toucan: Remove this file once live auction data is implemented +// This file contains mock data for testing the BidDistributionChart with different data sets + +import { MOCK_BID_DISTRIBUTION_DATA_10_TICKS } from 'components/Toucan/Auction/store/mocks/distributionData/10_Ticks' +import { MOCK_BID_DISTRIBUTION_DATA_20_TICKS } from 'components/Toucan/Auction/store/mocks/distributionData/20_Ticks' +import { MOCK_BID_DISTRIBUTION_DATA_50_TICKS } from 'components/Toucan/Auction/store/mocks/distributionData/50_Ticks' +import { MOCK_BID_DISTRIBUTION_DATA_100_TICKS } from 'components/Toucan/Auction/store/mocks/distributionData/100_Ticks' +import { BidDistributionData } from 'components/Toucan/Auction/store/types' + +export const MOCK_BID_DISTRIBUTION_DATASETS: BidDistributionData[] = [ + MOCK_BID_DISTRIBUTION_DATA_10_TICKS, + MOCK_BID_DISTRIBUTION_DATA_20_TICKS, + MOCK_BID_DISTRIBUTION_DATA_50_TICKS, + MOCK_BID_DISTRIBUTION_DATA_100_TICKS, +] diff --git a/apps/web/src/components/Toucan/Auction/store/mocks/useMockDataStore.ts b/apps/web/src/components/Toucan/Auction/store/mocks/useMockDataStore.ts new file mode 100644 index 00000000000..98c06b1d352 --- /dev/null +++ b/apps/web/src/components/Toucan/Auction/store/mocks/useMockDataStore.ts @@ -0,0 +1,97 @@ +// TODO | Toucan: Remove this file once live auction data is implemented +// Temporary zustand store for selecting mock bid distribution data in development + +import { SavedCustomPreset } from 'components/Toucan/Auction/BidDistributionChart/dev/customPresets' +import { MOCK_BID_DISTRIBUTION_DATASETS } from 'components/Toucan/Auction/store/mocks/distributionData/bidDistributionMockData' +import { BidDistributionData } from 'components/Toucan/Auction/store/types' +import { create } from 'zustand' + +interface MockDataState { + // Dataset selection + selectedDatasetIndex: number + selectedDataset: BidDistributionData + isCustomPreset: boolean + selectedPresetId: string | null // Track which custom preset is selected + setSelectedDatasetIndex: (index: number) => void + + // Test parameters (for customization) + bidTokenAddress: string + tickSize: string + clearingPrice: string + totalSupply: string + setTestParameters: (params: { + bidTokenAddress: string + tickSize: string + clearingPrice: string + totalSupply: string + }) => void + + // Custom preset management + loadCustomPreset: (preset: SavedCustomPreset) => void + resetToDefaults: () => void +} + +// Default values from FAKE_AUCTION_DATA +const DEFAULT_BID_TOKEN_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' // USDC +const DEFAULT_TICK_SIZE = '500000' // 0.50 USDC +const DEFAULT_CLEARING_PRICE = '5000000' // 5.00 USDC +const DEFAULT_TOTAL_SUPPLY = '1000000000000000000000000000' // 1B tokens with 18 decimals + +export const useMockDataStore = create((set) => ({ + // Dataset selection + selectedDatasetIndex: 0, + selectedDataset: MOCK_BID_DISTRIBUTION_DATASETS[0], + isCustomPreset: false, + selectedPresetId: null, + setSelectedDatasetIndex: (index: number) => + set({ + selectedDatasetIndex: index, + selectedDataset: MOCK_BID_DISTRIBUTION_DATASETS[index], + isCustomPreset: false, + selectedPresetId: null, // Clear preset ID when selecting quick preset + // Reset ALL test parameters to defaults when selecting quick presets + bidTokenAddress: DEFAULT_BID_TOKEN_ADDRESS, + tickSize: DEFAULT_TICK_SIZE, + clearingPrice: DEFAULT_CLEARING_PRICE, + totalSupply: DEFAULT_TOTAL_SUPPLY, + }), + + // Test parameters + bidTokenAddress: DEFAULT_BID_TOKEN_ADDRESS, + tickSize: DEFAULT_TICK_SIZE, + clearingPrice: DEFAULT_CLEARING_PRICE, + totalSupply: DEFAULT_TOTAL_SUPPLY, + setTestParameters: (params) => + set({ + bidTokenAddress: params.bidTokenAddress, + tickSize: params.tickSize, + clearingPrice: params.clearingPrice, + totalSupply: params.totalSupply, + }), + + // Custom preset management + loadCustomPreset: (preset) => { + set({ + selectedDataset: preset.distributionData, + selectedDatasetIndex: -1, // Indicates custom preset + isCustomPreset: true, + selectedPresetId: preset.id, // Store the specific preset ID + bidTokenAddress: preset.bidTokenAddress, // Load actual token address from preset + tickSize: preset.tickSize, + clearingPrice: preset.clearingPrice, + totalSupply: preset.totalSupply, + }) + }, + + resetToDefaults: () => + set({ + selectedDatasetIndex: 0, + selectedDataset: MOCK_BID_DISTRIBUTION_DATASETS[0], + isCustomPreset: false, + selectedPresetId: null, + bidTokenAddress: DEFAULT_BID_TOKEN_ADDRESS, + tickSize: DEFAULT_TICK_SIZE, + clearingPrice: DEFAULT_CLEARING_PRICE, + totalSupply: DEFAULT_TOTAL_SUPPLY, + }), +})) diff --git a/apps/web/src/hooks/useChainOutageConfig.ts b/apps/web/src/hooks/useChainOutageConfig.ts new file mode 100644 index 00000000000..af72fced7a3 --- /dev/null +++ b/apps/web/src/hooks/useChainOutageConfig.ts @@ -0,0 +1,20 @@ +import { DynamicConfigs, OutageBannerChainIdConfigKey, useDynamicConfigValue } from '@universe/gating' +import { ChainOutageData } from 'state/outage/types' +import { UniverseChainId } from 'uniswap/src/features/chains/types' + +export function useChainOutageConfig(): ChainOutageData | undefined { + const chainId = useDynamicConfigValue({ + config: DynamicConfigs.OutageBannerChainId, + key: OutageBannerChainIdConfigKey.ChainId, + defaultValue: undefined, + customTypeGuard: (x): x is UniverseChainId | undefined => { + return x === undefined || (typeof x === 'number' && x > 0) + }, + }) + + if (!chainId) { + return undefined + } + + return { chainId } +} diff --git a/apps/web/src/notification-service/notification-renderer/ModalNotification.tsx b/apps/web/src/notification-service/notification-renderer/ModalNotification.tsx new file mode 100644 index 00000000000..d8d67033e3f --- /dev/null +++ b/apps/web/src/notification-service/notification-renderer/ModalNotification.tsx @@ -0,0 +1,104 @@ +import { BackgroundType } from '@uniswap/client-notification-service/dist/uniswap/notificationservice/v1/api_pb' +import type { InAppNotification } from '@universe/api' +import type { NotificationClickTarget } from '@universe/notifications' +import { memo, useEffect, useMemo } from 'react' +import { + type ModalFeatureItem, + ModalTemplate, + type ModalTemplateButton, +} from 'uniswap/src/components/notifications/ModalTemplate' +import { useEvent } from 'utilities/src/react/hooks' + +interface ModalNotificationProps { + notification: InAppNotification + onNotificationClick?: (notificationId: string, target: NotificationClickTarget) => void + onNotificationShown?: (notificationId: string) => void +} + +/** + * ModalNotification component + * + * A wrapper around ModalTemplate for rendering notification API-driven modals. + * Delegates click handling to the NotificationService. + * + * Features: + * - Maps notification API types to ModalTemplate props + * - Converts content.body items to feature list + * - Maps content.buttons to action buttons + * - Delegates click actions to NotificationService via onNotificationClick + * - Extracts background images and icons from notification content + */ +export const ModalNotification = memo(function ModalNotification({ + notification, + onNotificationClick, + onNotificationShown, +}: ModalNotificationProps) { + // Content is always defined when this component is rendered (checked in NotificationContainer) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const content = notification.content! + + const handleClose = useEvent(() => { + onNotificationClick?.(notification.id, { type: 'dismiss' }) + }) + + const handleBackgroundPress = useEvent(() => { + onNotificationClick?.(notification.id, { type: 'background' }) + }) + + const backgroundImageUrl = useMemo(() => { + const background = content.background + if (background && background.backgroundType === BackgroundType.IMAGE && background.link) { + return background.link + } + return undefined + }, [content.background]) + + const hasBackgroundClick = useMemo(() => { + const background = content.background + return background?.backgroundOnClick && background.backgroundOnClick.onClick.length > 0 + }, [content.background]) + + const features = useMemo((): ModalFeatureItem[] => { + if (!content.body?.items) { + return [] + } + + return content.body.items.map((item) => ({ + text: item.text, + iconUrl: item.iconUrl, + })) + }, [content.body]) + + const buttons = useMemo((): ModalTemplateButton[] => { + if (content.buttons.length === 0) { + return [] + } + + return content.buttons.map((button, index) => ({ + text: button.text, + isPrimary: button.isPrimary, + onPress: () => { + onNotificationClick?.(notification.id, { type: 'button', index }) + }, + })) + }, [content.buttons, notification.id, onNotificationClick]) + + useEffect(() => { + onNotificationShown?.(notification.id) + }, [notification.id, onNotificationShown]) + + return ( + + ) +}) diff --git a/apps/web/src/pages/Portfolio/ConnectWalletBottomOverlay.tsx b/apps/web/src/pages/Portfolio/ConnectWalletBottomOverlay.tsx new file mode 100644 index 00000000000..1d3023714b3 --- /dev/null +++ b/apps/web/src/pages/Portfolio/ConnectWalletBottomOverlay.tsx @@ -0,0 +1,47 @@ +import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' +import { useTranslation } from 'react-i18next' +import { Button, Flex, Text } from 'ui/src' + +export function ConnectWalletBottomOverlay(): JSX.Element { + const accountDrawer = useAccountDrawer() + const { t } = useTranslation() + + return ( + + + + {t('portfolio.disconnected.connectWallet.cta')} + + + + + ) +} diff --git a/apps/web/src/pages/Portfolio/Header/hooks/useIsConnected.ts b/apps/web/src/pages/Portfolio/Header/hooks/useIsConnected.ts new file mode 100644 index 00000000000..6c381c705ec --- /dev/null +++ b/apps/web/src/pages/Portfolio/Header/hooks/useIsConnected.ts @@ -0,0 +1,7 @@ +/* eslint-disable-next-line no-restricted-imports, no-restricted-syntax */ +import { useAccount } from 'hooks/useAccount' + +export default function useIsConnected() { + const account = useAccount() + return !!account.address +} diff --git a/apps/web/src/pages/Portfolio/NFTs/utils/filterNfts.test.ts b/apps/web/src/pages/Portfolio/NFTs/utils/filterNfts.test.ts new file mode 100644 index 00000000000..b208030e60d --- /dev/null +++ b/apps/web/src/pages/Portfolio/NFTs/utils/filterNfts.test.ts @@ -0,0 +1,257 @@ +import { filterNft } from 'pages/Portfolio/NFTs/utils/filterNfts' +import { NFTItem } from 'uniswap/src/features/nfts/types' + +describe('filterNft', () => { + const createMockNft = (overrides: Partial = {}): NFTItem => ({ + name: 'Bored Ape #1234', + collectionName: 'Bored Ape Yacht Club', + tokenId: '1234', + contractAddress: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D', + ...overrides, + }) + + describe('when search query is empty', () => { + it('should return true for empty string', () => { + const nft = createMockNft() + expect(filterNft(nft, '')).toBe(true) + }) + + it('should return true for whitespace-only string', () => { + const nft = createMockNft() + expect(filterNft(nft, ' ')).toBe(true) + }) + + it('should return true for null/undefined search', () => { + const nft = createMockNft() + expect(filterNft(nft, '')).toBe(true) + }) + }) + + describe('when searching by NFT name', () => { + it('should match exact name', () => { + const nft = createMockNft({ name: 'Bored Ape #1234' }) + expect(filterNft(nft, 'Bored Ape #1234')).toBe(true) + }) + + it('should match partial name', () => { + const nft = createMockNft({ name: 'Bored Ape #1234' }) + expect(filterNft(nft, 'Bored')).toBe(true) + }) + + it('should be case-insensitive', () => { + const nft = createMockNft({ name: 'Bored Ape #1234' }) + expect(filterNft(nft, 'bored')).toBe(true) + expect(filterNft(nft, 'BORED')).toBe(true) + expect(filterNft(nft, 'BoReD')).toBe(true) + }) + + it('should not match when name does not contain search term', () => { + const nft = createMockNft({ name: 'Bored Ape #1234' }) + expect(filterNft(nft, 'CryptoPunk')).toBe(false) + }) + + it('should handle undefined name', () => { + const nft = createMockNft({ + name: undefined, + collectionName: undefined, + tokenId: undefined, + contractAddress: undefined, + }) + expect(filterNft(nft, 'Bored')).toBe(false) + }) + + it('should handle null name', () => { + const nft = createMockNft({ + name: null as any, + collectionName: undefined, + tokenId: undefined, + contractAddress: undefined, + }) + expect(filterNft(nft, 'Bored')).toBe(false) + }) + }) + + describe('when searching by collection name', () => { + it('should match exact collection name', () => { + const nft = createMockNft({ collectionName: 'Bored Ape Yacht Club' }) + expect(filterNft(nft, 'Bored Ape Yacht Club')).toBe(true) + }) + + it('should match partial collection name', () => { + const nft = createMockNft({ collectionName: 'Bored Ape Yacht Club' }) + expect(filterNft(nft, 'Yacht')).toBe(true) + }) + + it('should be case-insensitive', () => { + const nft = createMockNft({ collectionName: 'Bored Ape Yacht Club' }) + expect(filterNft(nft, 'yacht')).toBe(true) + expect(filterNft(nft, 'YACHT')).toBe(true) + expect(filterNft(nft, 'YaChT')).toBe(true) + }) + + it('should not match when collection name does not contain search term', () => { + const nft = createMockNft({ collectionName: 'Bored Ape Yacht Club' }) + expect(filterNft(nft, 'CryptoPunks')).toBe(false) + }) + + it('should handle undefined collection name', () => { + const nft = createMockNft({ collectionName: undefined }) + expect(filterNft(nft, 'Yacht')).toBe(false) + }) + }) + + describe('when searching by token ID', () => { + it('should match exact token ID', () => { + const nft = createMockNft({ tokenId: '1234' }) + expect(filterNft(nft, '1234')).toBe(true) + }) + + it('should match partial token ID', () => { + const nft = createMockNft({ tokenId: '1234' }) + expect(filterNft(nft, '123')).toBe(true) + }) + + it('should be case-insensitive', () => { + const nft = createMockNft({ tokenId: 'ABC123' }) + expect(filterNft(nft, 'abc')).toBe(true) + expect(filterNft(nft, 'ABC')).toBe(true) + expect(filterNft(nft, 'AbC')).toBe(true) + }) + + it('should not match when token ID does not contain search term', () => { + const nft = createMockNft({ tokenId: '1234' }) + expect(filterNft(nft, '5678')).toBe(false) + }) + + it('should handle undefined token ID', () => { + const nft = createMockNft({ + tokenId: undefined, + name: undefined, + collectionName: undefined, + contractAddress: undefined, + }) + expect(filterNft(nft, '1234')).toBe(false) + }) + }) + + describe('when searching by contract address', () => { + it('should match exact contract address', () => { + const nft = createMockNft({ contractAddress: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D' }) + expect(filterNft(nft, '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D')).toBe(true) + }) + + it('should match partial contract address', () => { + const nft = createMockNft({ contractAddress: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D' }) + expect(filterNft(nft, 'BC4CA0')).toBe(true) + }) + + it('should be case-insensitive', () => { + const nft = createMockNft({ contractAddress: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D' }) + expect(filterNft(nft, 'bc4ca0')).toBe(true) + expect(filterNft(nft, 'BC4CA0')).toBe(true) + expect(filterNft(nft, 'Bc4Ca0')).toBe(true) + }) + + it('should not match when contract address does not contain search term', () => { + const nft = createMockNft({ contractAddress: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D' }) + expect(filterNft(nft, '0x123456789')).toBe(false) + }) + + it('should handle undefined contract address', () => { + const nft = createMockNft({ contractAddress: undefined }) + expect(filterNft(nft, 'BC4CA0')).toBe(false) + }) + }) + + describe('when searching with whitespace', () => { + it('should trim leading and trailing whitespace', () => { + const nft = createMockNft({ name: 'Bored Ape #1234' }) + expect(filterNft(nft, ' Bored ')).toBe(true) + expect(filterNft(nft, '\tBored\n')).toBe(true) + }) + + it('should handle whitespace-only search as empty search', () => { + const nft = createMockNft() + expect(filterNft(nft, ' ')).toBe(true) + expect(filterNft(nft, '\t\n')).toBe(true) + }) + }) + + describe('edge cases', () => { + it('should handle NFT with all undefined fields', () => { + const nft = createMockNft({ + name: undefined, + collectionName: undefined, + tokenId: undefined, + contractAddress: undefined, + }) + expect(filterNft(nft, 'anything')).toBe(false) + }) + + it('should handle NFT with empty string fields', () => { + const nft = createMockNft({ + name: '', + collectionName: '', + tokenId: '', + contractAddress: '', + }) + expect(filterNft(nft, 'anything')).toBe(false) + }) + + it('should handle special characters in search', () => { + const nft = createMockNft({ name: 'NFT #1234' }) + expect(filterNft(nft, '#')).toBe(true) + expect(filterNft(nft, '1234')).toBe(true) + }) + + it('should handle unicode characters', () => { + const nft = createMockNft({ name: '🚀 Rocket NFT' }) + expect(filterNft(nft, '🚀')).toBe(true) + expect(filterNft(nft, 'Rocket')).toBe(true) + }) + }) + + describe('real-world examples', () => { + it('should match Bored Ape Yacht Club NFT', () => { + const nft = createMockNft({ + name: 'Bored Ape #1234', + collectionName: 'Bored Ape Yacht Club', + tokenId: '1234', + contractAddress: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D', + }) + + expect(filterNft(nft, 'bored')).toBe(true) + expect(filterNft(nft, 'ape')).toBe(true) + expect(filterNft(nft, 'yacht')).toBe(true) + expect(filterNft(nft, '1234')).toBe(true) + expect(filterNft(nft, 'BC4CA0')).toBe(true) + }) + + it('should match CryptoPunks NFT', () => { + const nft = createMockNft({ + name: 'CryptoPunk #1234', + collectionName: 'CryptoPunks', + tokenId: '1234', + contractAddress: '0xb47e3cd837dDF8e4c57F05d70Ab865de6e193BBB', + }) + + expect(filterNft(nft, 'crypto')).toBe(true) + expect(filterNft(nft, 'punk')).toBe(true) + expect(filterNft(nft, 'punks')).toBe(true) + expect(filterNft(nft, '1234')).toBe(true) + }) + + it('should not match unrelated NFTs', () => { + const nft = createMockNft({ + name: 'Bored Ape #1234', + collectionName: 'Bored Ape Yacht Club', + tokenId: '1234', + contractAddress: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D', + }) + + expect(filterNft(nft, 'cryptopunk')).toBe(false) + expect(filterNft(nft, 'azuki')).toBe(false) + expect(filterNft(nft, '5678')).toBe(false) + }) + }) +}) diff --git a/apps/web/src/pages/Portfolio/NFTs/utils/filterNfts.ts b/apps/web/src/pages/Portfolio/NFTs/utils/filterNfts.ts new file mode 100644 index 00000000000..643712033f0 --- /dev/null +++ b/apps/web/src/pages/Portfolio/NFTs/utils/filterNfts.ts @@ -0,0 +1,32 @@ +import { NFTItem } from 'uniswap/src/features/nfts/types' + +/** + * Filters an NFT item based on a search query. + * The search is case-insensitive and matches against: + * - NFT name + * - Collection name + * - Token ID + * - Contract address + * + * @param item - The NFT item to filter + * @param searchQuery - The search query (will be converted to lowercase) + * @returns true if the item matches the search query, false otherwise + */ +export function filterNft(item: NFTItem, searchQuery: string): boolean { + if (!searchQuery.trim()) { + return true + } + + const lowercaseSearch = searchQuery.trim().toLowerCase() + const name = item.name?.toLowerCase() ?? '' + const collectionName = item.collectionName?.toLowerCase() ?? '' + const tokenId = item.tokenId?.toLowerCase() ?? '' + const contract = item.contractAddress?.toLowerCase() ?? '' + + return ( + name.includes(lowercaseSearch) || + collectionName.includes(lowercaseSearch) || + tokenId.includes(lowercaseSearch) || + contract.includes(lowercaseSearch) + ) +} diff --git a/apps/web/src/pages/Portfolio/PortfolioDisconnectedView.tsx b/apps/web/src/pages/Portfolio/PortfolioDisconnectedView.tsx new file mode 100644 index 00000000000..fe9e4c354d6 --- /dev/null +++ b/apps/web/src/pages/Portfolio/PortfolioDisconnectedView.tsx @@ -0,0 +1,105 @@ +import DISCONNECTED_B_DARK from 'assets/images/portfolio-page-promo/dark.svg' +import DISCONNECTED_B_LIGHT from 'assets/images/portfolio-page-promo/light.svg' +import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' +import { useTranslation } from 'react-i18next' +import { Button, Flex, Image, Text, useIsDarkMode, useSporeColors } from 'ui/src' +import { INTERFACE_NAV_HEIGHT } from 'ui/src/theme' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' + +const PADDING_TOP = 60 +const NAV_BORDER_WIDTH = 1 +const OFFSET_TOP = INTERFACE_NAV_HEIGHT + NAV_BORDER_WIDTH +const LEFT_CONTENT_MAX_WIDTH = 262 + +export default function PortfolioDisconnectedView() { + const { t } = useTranslation() + const enabledChains = useEnabledChains() + const isDarkMode = useIsDarkMode() + const accountDrawer = useAccountDrawer() + const colors = useSporeColors() + + return ( + + + + + {t('common.getStarted')} + + + {t('portfolio.disconnected.cta.description', { numNetworks: enabledChains.chains.length })} + + + + + + + + + + + ) +} diff --git a/apps/web/src/pages/Portfolio/hooks/usePortfolioAddress.ts b/apps/web/src/pages/Portfolio/hooks/usePortfolioAddress.ts new file mode 100644 index 00000000000..b3909dc2a0d --- /dev/null +++ b/apps/web/src/pages/Portfolio/hooks/usePortfolioAddress.ts @@ -0,0 +1,13 @@ +/* eslint-disable-next-line no-restricted-imports, no-restricted-syntax */ +import { useAccount } from 'hooks/useAccount' + +// This is the address used for the disconnected demo view. It is only used in the disconnected state for the portfolio page. +const DEMO_WALLET_ADDRESS = '0x8796207d877194d97a2c360c041f13887896FC79' + +export function usePortfolioAddress() { + const account = useAccount() + if (!account.address) { + return DEMO_WALLET_ADDRESS + } + return account.address +} diff --git a/apps/web/src/playwright/fixtures/graphql.ts b/apps/web/src/playwright/fixtures/graphql.ts index 0cf452ce60c..4be972416da 100644 --- a/apps/web/src/playwright/fixtures/graphql.ts +++ b/apps/web/src/playwright/fixtures/graphql.ts @@ -59,7 +59,7 @@ export const test = base.extend({ } } - await page.route(/(?:interface|beta).(gateway|api).uniswap.org\/v1\/graphql/, async (route) => { + await page.route(/(?:interface|beta)\.(gateway|api)\.uniswap\.org\/v1\/graphql/, async (route) => { const request = route.request() const postData = request.postData() if (!postData) { diff --git a/apps/web/src/state/explore/topAuctions.ts b/apps/web/src/state/explore/topAuctions.ts new file mode 100644 index 00000000000..467400ca83d --- /dev/null +++ b/apps/web/src/state/explore/topAuctions.ts @@ -0,0 +1,295 @@ +import { Auction } from 'uniswap/src/data/rest/auctions/types' + +// Mock data - realistic auction values +const MOCK_TOUCAN_AUCTIONS: Auction[] = [ + { + auction_id: '0x5A9CBE916BDb1Df8bbaeE55a4454678886B17baf', + chain_id: 11155111, + token_name: 'CryptoKitties Genesis', + token_symbol: 'CKGEN', + token_address: '0x0000000000000000000000000000000000000000', + creator_address: '0x0000000000000000000000000000000000000000', + start_block: '1000000000000000000', + end_block: '1000000000000000000', + total_supply: '1000000000000000000', + tick_size: '1000000000000000000', + graduation_threshold_mps: '1000000000000000000', + bid_token_address: '0x0000000000000000000000000000000000000000', + }, + { + auction_id: '0xf96613C4dA1B0c1e3E0C8fdf24027089f4406e34', + chain_id: 11155111, + token_name: 'Bored Ape #7890', + token_symbol: 'BAYC', + token_address: '0x0000000000000000000000000000000000000000', + creator_address: '0x0000000000000000000000000000000000000000', + start_block: '1000000000000000000', + end_block: '1000000000000000000', + total_supply: '1000000000000000000', + tick_size: '1000000000000000000', + graduation_threshold_mps: '1000000000000000000', + bid_token_address: '0x0000000000000000000000000000000000000000', + }, + { + auction_id: '123', + chain_id: 1, + token_name: 'Azuki Elemental', + token_symbol: 'AZUKI', + token_address: '0x0000000000000000000000000000000000000000', + creator_address: '0x0000000000000000000000000000000000000000', + start_block: '1000000000000000000', + end_block: '1000000000000000000', + total_supply: '1000000000000000000', + tick_size: '1000000000000000000', + graduation_threshold_mps: '1000000000000000000', + bid_token_address: '0x0000000000000000000000000000000000000000', + }, + { + auction_id: '123', + chain_id: 1, + token_name: 'Pudgy Penguin Rare', + token_symbol: 'PPR', + token_address: '0x0000000000000000000000000000000000000000', + creator_address: '0x0000000000000000000000000000000000000000', + start_block: '1000000000000000000', + end_block: '1000000000000000000', + total_supply: '1000000000000000000', + tick_size: '1000000000000000000', + graduation_threshold_mps: '1000000000000000000', + bid_token_address: '0x0000000000000000000000000000000000000000', + }, + { + auction_id: '123', + chain_id: 1, + token_name: 'Moonbirds Mythic', + token_symbol: 'MOON', + token_address: '0x0000000000000000000000000000000000000000', + creator_address: '0x0000000000000000000000000000000000000000', + start_block: '1000000000000000000', + end_block: '1000000000000000000', + total_supply: '1000000000000000000', + tick_size: '1000000000000000000', + graduation_threshold_mps: '1000000000000000000', + bid_token_address: '0x0000000000000000000000000000000000000000', + }, + { + auction_id: '123', + chain_id: 1, + token_name: 'Doodles Legendary', + token_symbol: 'DOOD', + token_address: '0x0000000000000000000000000000000000000000', + creator_address: '0x0000000000000000000000000000000000000000', + start_block: '1000000000000000000', + end_block: '1000000000000000000', + total_supply: '1000000000000000000', + tick_size: '1000000000000000000', + graduation_threshold_mps: '1000000000000000000', + bid_token_address: '0x0000000000000000000000000000000000000000', + }, + { + auction_id: '123', + chain_id: 1, + token_name: 'CloneX Alien', + token_symbol: 'CLX', + token_address: '0x0000000000000000000000000000000000000000', + creator_address: '0x0000000000000000000000000000000000000000', + start_block: '1000000000000000000', + end_block: '1000000000000000000', + total_supply: '1000000000000000000', + tick_size: '1000000000000000000', + graduation_threshold_mps: '1000000000000000000', + bid_token_address: '0x0000000000000000000000000000000000000000', + }, + { + auction_id: '123', + chain_id: 1, + token_name: 'Art Blocks Chromie', + token_symbol: 'ABCH', + token_address: '0x0000000000000000000000000000000000000000', + creator_address: '0x0000000000000000000000000000000000000000', + start_block: '1000000000000000000', + end_block: '1000000000000000000', + total_supply: '1000000000000000000', + tick_size: '1000000000000000000', + graduation_threshold_mps: '1000000000000000000', + bid_token_address: '0x0000000000000000000000000000000000000000', + }, + { + auction_id: '123', + chain_id: 1, + token_name: 'Meebits Avatar', + token_symbol: 'MEEB', + token_address: '0x0000000000000000000000000000000000000000', + creator_address: '0x0000000000000000000000000000000000000000', + start_block: '1000000000000000000', + end_block: '1000000000000000000', + total_supply: '1000000000000000000', + tick_size: '1000000000000000000', + graduation_threshold_mps: '1000000000000000000', + bid_token_address: '0x0000000000000000000000000000000000000000', + }, + { + auction_id: '123', + chain_id: 1, + token_name: 'Otherside Land', + token_symbol: 'OSIDE', + token_address: '0x0000000000000000000000000000000000000000', + creator_address: '0x0000000000000000000000000000000000000000', + start_block: '1000000000000000000', + end_block: '1000000000000000000', + total_supply: '1000000000000000000', + tick_size: '1000000000000000000', + graduation_threshold_mps: '1000000000000000000', + bid_token_address: '0x0000000000000000000000000000000000000000', + }, + { + auction_id: '123', + chain_id: 1, + token_name: 'Mutant Ape #4567', + token_symbol: 'MAYC', + token_address: '0x0000000000000000000000000000000000000000', + creator_address: '0x0000000000000000000000000000000000000000', + start_block: '1000000000000000000', + end_block: '1000000000000000000', + total_supply: '1000000000000000000', + tick_size: '1000000000000000000', + graduation_threshold_mps: '1000000000000000000', + bid_token_address: '0x0000000000000000000000000000000000000000', + }, + { + auction_id: '123', + chain_id: 1, + token_name: 'Goblintown Rare', + token_symbol: 'GOBL', + token_address: '0x0000000000000000000000000000000000000000', + creator_address: '0x0000000000000000000000000000000000000000', + start_block: '1000000000000000000', + end_block: '1000000000000000000', + total_supply: '1000000000000000000', + tick_size: '1000000000000000000', + graduation_threshold_mps: '1000000000000000000', + bid_token_address: '0x0000000000000000000000000000000000000000', + }, + { + auction_id: '123', + chain_id: 1, + token_name: 'VeeFriends Series', + token_symbol: 'VEE', + token_address: '0x0000000000000000000000000000000000000000', + creator_address: '0x0000000000000000000000000000000000000000', + start_block: '1000000000000000000', + end_block: '1000000000000000000', + total_supply: '1000000000000000000', + tick_size: '1000000000000000000', + graduation_threshold_mps: '1000000000000000000', + bid_token_address: '0x0000000000000000000000000000000000000000', + }, + { + auction_id: '123', + chain_id: 1, + token_name: 'Crypto Punks V1', + token_symbol: 'CPV1', + token_address: '0x0000000000000000000000000000000000000000', + creator_address: '0x0000000000000000000000000000000000000000', + start_block: '1000000000000000000', + end_block: '1000000000000000000', + total_supply: '1000000000000000000', + tick_size: '1000000000000000000', + graduation_threshold_mps: '1000000000000000000', + bid_token_address: '0x0000000000000000000000000000000000000000', + }, + { + auction_id: '123', + chain_id: 1, + token_name: 'World of Women', + token_symbol: 'WOW', + token_address: '0x0000000000000000000000000000000000000000', + creator_address: '0x0000000000000000000000000000000000000000', + start_block: '1000000000000000000', + end_block: '1000000000000000000', + total_supply: '1000000000000000000', + tick_size: '1000000000000000000', + graduation_threshold_mps: '1000000000000000000', + bid_token_address: '0x0000000000000000000000000000000000000000', + }, + { + auction_id: '123', + chain_id: 1, + token_name: 'Cool Cats NFT', + token_symbol: 'COOL', + token_address: '0x0000000000000000000000000000000000000000', + creator_address: '0x0000000000000000000000000000000000000000', + start_block: '1000000000000000000', + end_block: '1000000000000000000', + total_supply: '1000000000000000000', + tick_size: '1000000000000000000', + graduation_threshold_mps: '1000000000000000000', + bid_token_address: '0x0000000000000000000000000000000000000000', + }, + { + auction_id: '123', + chain_id: 1, + token_name: 'Sandbox Alpha', + token_symbol: 'SBOX', + token_address: '0x0000000000000000000000000000000000000000', + creator_address: '0x0000000000000000000000000000000000000000', + start_block: '1000000000000000000', + end_block: '1000000000000000000', + total_supply: '1000000000000000000', + tick_size: '1000000000000000000', + graduation_threshold_mps: '1000000000000000000', + bid_token_address: '0x0000000000000000000000000000000000000000', + }, + { + auction_id: '123', + chain_id: 1, + token_name: 'DeGods Genesis', + token_symbol: 'DEGOD', + token_address: '0x0000000000000000000000000000000000000000', + creator_address: '0x0000000000000000000000000000000000000000', + start_block: '1000000000000000000', + end_block: '1000000000000000000', + total_supply: '1000000000000000000', + tick_size: '1000000000000000000', + graduation_threshold_mps: '1000000000000000000', + bid_token_address: '0x0000000000000000000000000000000000000000', + }, + { + auction_id: '123', + chain_id: 1, + token_name: 'Fidenza Algorithm', + token_symbol: 'FIDEN', + token_address: '0x0000000000000000000000000000000000000000', + creator_address: '0x0000000000000000000000000000000000000000', + start_block: '1000000000000000000', + end_block: '1000000000000000000', + total_supply: '1000000000000000000', + tick_size: '1000000000000000000', + graduation_threshold_mps: '1000000000000000000', + bid_token_address: '0x0000000000000000000000000000000000000000', + }, + { + auction_id: '123', + chain_id: 1, + token_name: 'Loot Project', + token_symbol: 'LOOT', + token_address: '0x0000000000000000000000000000000000000000', + creator_address: '0x0000000000000000000000000000000000000000', + start_block: '1000000000000000000', + end_block: '1000000000000000000', + total_supply: '1000000000000000000', + tick_size: '1000000000000000000', + graduation_threshold_mps: '1000000000000000000', + bid_token_address: '0x0000000000000000000000000000000000000000', + }, +] + +export function useTopAuctions() { + // In the future, this will fetch from an API + // For now, return mock data + return { + topAuctions: MOCK_TOUCAN_AUCTIONS, + isLoading: false, + isError: false, + } +} diff --git a/apps/web/src/state/migrations/16.ts b/apps/web/src/state/migrations/16.ts index f0bd2d05551..ec3b7e492d7 100644 --- a/apps/web/src/state/migrations/16.ts +++ b/apps/web/src/state/migrations/16.ts @@ -1,11 +1,11 @@ -import { type PersistState } from 'redux-persist' -import { type TokensState } from 'uniswap/src/features/tokens/warnings/slice/slice' -import { type SerializedTokenMap, type TokenDismissInfo } from 'uniswap/src/features/tokens/warnings/slice/types' +import { PersistState } from 'redux-persist' +import { TokensState } from 'uniswap/src/features/tokens/slice/slice' +import { SerializedTokenMap } from 'uniswap/src/features/tokens/slice/types' export type PersistAppStateV16 = { _persist: PersistState user?: { - tokens: SerializedTokenMap + tokens: SerializedTokenMap } tokens?: TokensState } @@ -26,7 +26,9 @@ export const migration16 = (state: PersistAppStateV16 | undefined) => { } // remove old tokens slice - delete newState.user?.tokens + if (newState.user) { + delete newState.user.tokens + } return { ...newState, _persist: { ...state._persist, version: 16 } } } diff --git a/apps/web/src/state/migrations/58.test.ts b/apps/web/src/state/migrations/58.test.ts index 0b40ecae500..e0c641e0720 100644 --- a/apps/web/src/state/migrations/58.test.ts +++ b/apps/web/src/state/migrations/58.test.ts @@ -1,5 +1,4 @@ import { Language } from 'uniswap/src/features/language/constants' -import { migration58 } from '~/state/migrations/58' const previousState = { _persist: { diff --git a/apps/web/src/state/migrations/58.ts b/apps/web/src/state/migrations/58.ts index 0ef72271616..acd8187997c 100644 --- a/apps/web/src/state/migrations/58.ts +++ b/apps/web/src/state/migrations/58.ts @@ -1,6 +1,5 @@ import { PersistState } from 'redux-persist' import { Language, mapLocaleToLanguage } from 'uniswap/src/features/language/constants' -import { navigatorLocale } from 'uniswap/src/features/language/navigatorLocale' type PersistAppStateV58 = { _persist: PersistState diff --git a/apps/web/src/state/migrations/59.test.ts b/apps/web/src/state/migrations/59.test.ts index 859bfd8adbb..cdef670cfb2 100644 --- a/apps/web/src/state/migrations/59.test.ts +++ b/apps/web/src/state/migrations/59.test.ts @@ -1,5 +1,3 @@ -import { migration59 } from '~/state/migrations/59' - const previousState = { _persist: { version: 58, diff --git a/apps/web/src/state/outage/atoms.ts b/apps/web/src/state/outage/atoms.ts new file mode 100644 index 00000000000..80eb11e719d --- /dev/null +++ b/apps/web/src/state/outage/atoms.ts @@ -0,0 +1,9 @@ +import { atomWithReset } from 'jotai/utils' +import { ChainOutageData } from 'state/outage/types' + +/** + * Global atom for the currently displayed outage banner. + * Updated by useUpdateManualOutage hook based on error detection. + * Read by OutageBanners component to determine if/what to display. + */ +export const manualChainOutageAtom = atomWithReset(undefined) diff --git a/apps/web/src/utils/setupTurnstileCSPErrorFilter.ts b/apps/web/src/utils/setupTurnstileCSPErrorFilter.ts index 2935d64f80c..168c649a567 100644 --- a/apps/web/src/utils/setupTurnstileCSPErrorFilter.ts +++ b/apps/web/src/utils/setupTurnstileCSPErrorFilter.ts @@ -16,6 +16,25 @@ export function setupTurnstileCSPErrorFilter(): void { /** * Checks if an error string or URI is related to Turnstile CSP violations */ + function hasAllowedTurnstileHost(text: string): boolean { + const allowedHosts = new Set(['challenges.cloudflare.com']) + + // Split free-form text into tokens and parse URL-like values safely. + const tokens = text.split(/\s+/) + for (const token of tokens) { + try { + const parsed = new URL(token) + if (allowedHosts.has(parsed.hostname.toLowerCase())) { + return true + } + } catch { + // Ignore non-URL tokens. + } + } + + return false + } + function isTurnstileCSPError(text: string): boolean { const lowerText = text.toLowerCase() @@ -29,7 +48,7 @@ export function setupTurnstileCSPErrorFilter(): void { // Check for Turnstile-related identifiers const hasTurnstileIdentifier = - lowerText.includes('challenges.cloudflare.com') || + hasAllowedTurnstileHost(text) || lowerText.includes('cdn-cgi/challenge-platform') || lowerText.includes('turnstile') || lowerText.includes('normal?lang=auto') || diff --git a/package.json b/package.json index d6b2cf61f79..312dca18f8f 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "i18next": "23.10.0", "i18next-parser": "8.6.0", "inquirer": "8.2.6", - "js-yaml": "4.1.0", + "js-yaml": "4.1.1", "knip": "5.50.5", "lefthook": "1.12.2", "moti": "0.29.0", diff --git a/packages/notifications/src/getIsNotificationServiceEnabled.ts b/packages/notifications/src/getIsNotificationServiceEnabled.ts new file mode 100644 index 00000000000..34cf6d2abae --- /dev/null +++ b/packages/notifications/src/getIsNotificationServiceEnabled.ts @@ -0,0 +1,7 @@ +const IS_NOTIFICATION_SERVICE_ENABLED = false + +function getIsNotificationServiceEnabled(): boolean { + return IS_NOTIFICATION_SERVICE_ENABLED +} + +export { getIsNotificationServiceEnabled } diff --git a/packages/sessions/package.json b/packages/sessions/package.json index 462f424283f..f8cf0dd713c 100644 --- a/packages/sessions/package.json +++ b/packages/sessions/package.json @@ -35,7 +35,7 @@ "@typescript/native-preview": "7.0.0-dev.20260311.1", "@vitest/coverage-v8": "3.2.1", "depcheck": "1.4.7", - "happy-dom": "20.0.10", + "happy-dom": "20.8.9", "typescript": "5.8.3", "vitest": "3.2.1" }, diff --git a/packages/sessions/src/challenge-solvers/turnstileSolver.integration.test.ts b/packages/sessions/src/challenge-solvers/turnstileSolver.integration.test.ts index cc74851befe..53193a3ef0a 100644 --- a/packages/sessions/src/challenge-solvers/turnstileSolver.integration.test.ts +++ b/packages/sessions/src/challenge-solvers/turnstileSolver.integration.test.ts @@ -39,7 +39,16 @@ beforeAll(() => { }) vi.spyOn(document.head, 'appendChild').mockImplementation((node) => { - if (node instanceof HTMLScriptElement && node.src.includes('challenges.cloudflare.com')) { + let isTurnstileScript = false + if (node instanceof HTMLScriptElement && node.src) { + try { + const url = new URL(node.src, window.location.href) + isTurnstileScript = url.hostname === 'challenges.cloudflare.com' + } catch { + isTurnstileScript = false + } + } + if (isTurnstileScript) { // Simulate script load immediately setTimeout(() => { // Set up the mock turnstile API @@ -262,7 +271,16 @@ describe('Turnstile Solver Integration Tests', () => { it('handles script loading failures', async () => { // Mock script loading failure vi.spyOn(document.head, 'appendChild').mockImplementationOnce((node) => { - if (node instanceof HTMLScriptElement && node.src.includes('challenges.cloudflare.com')) { + let isTurnstileScript = false + if (node instanceof HTMLScriptElement && node.src) { + try { + const url = new URL(node.src, window.location.href) + isTurnstileScript = url.hostname === 'challenges.cloudflare.com' + } catch { + isTurnstileScript = false + } + } + if (isTurnstileScript) { setTimeout(() => { if (node.onerror) { node.onerror({} as Event) diff --git a/packages/uniswap/package.json b/packages/uniswap/package.json index 562b8d7445b..2b7f41edc40 100644 --- a/packages/uniswap/package.json +++ b/packages/uniswap/package.json @@ -93,7 +93,7 @@ "idb-keyval": "6.2.1", "jotai": "1.3.7", "jsbi": "3.2.5", - "lodash": "4.17.23", + "lodash": "4.18.1", "ms": "2.1.3", "poisson-disk-sampling": "2.3.1", "qs": "6.14.2", @@ -109,7 +109,7 @@ "react-native-reanimated": "3.19.3", "react-native-svg": "15.13.0", "react-redux": "8.0.5", - "react-router": "7.6.3", + "react-router": "7.12.0", "react-test-renderer": "19.0.3", "react-virtualized-auto-sizer": "1.0.20", "react-window": "1.8.9", diff --git a/packages/uniswap/src/components/modals/WarningModal/types.ts b/packages/uniswap/src/components/modals/WarningModal/types.ts index a81735c11c7..014a05e4d4a 100644 --- a/packages/uniswap/src/components/modals/WarningModal/types.ts +++ b/packages/uniswap/src/components/modals/WarningModal/types.ts @@ -51,6 +51,7 @@ export enum WarningLabel { NetworkError = 'network_error', BlockedToken = 'blocked_token', NoQuotesFound = 'no_quotes_found', + AztecUnavailable = 'aztec_unavailable', } export interface Warning { diff --git a/packages/uniswap/src/components/notifications/MonadAnnouncementModal.tsx b/packages/uniswap/src/components/notifications/MonadAnnouncementModal.tsx new file mode 100644 index 00000000000..a06cdffccd8 --- /dev/null +++ b/packages/uniswap/src/components/notifications/MonadAnnouncementModal.tsx @@ -0,0 +1,57 @@ +import { useTranslation } from 'react-i18next' +import { MONAD_LOGO_FILLED, MONAD_TEST_BANNER_LIGHT } from 'ui/src/assets' +import { + type ModalFeatureItem, + ModalTemplate, + type ModalTemplateButton, +} from 'uniswap/src/components/notifications/ModalTemplate' + +interface MonadAnnouncementModalProps { + isOpen: boolean + onClose: () => void + onExplorePress: () => void +} + +/** + * Static modal component for Monad announcement + */ +export function MonadAnnouncementModal({ isOpen, onClose, onExplorePress }: MonadAnnouncementModalProps): JSX.Element { + const { t } = useTranslation() + + const features: ModalFeatureItem[] = [ + { + text: t('notification.monad.feature.searchSwap'), + iconUrl: 'custom:coin-convert-$neutral2', + }, + { + text: t('notification.monad.feature.compatibleWallets'), + iconUrl: 'custom:ethereum-$neutral2', + }, + { + text: t('notification.monad.feature.zeroGas'), + iconUrl: 'custom:gas-$neutral2', + }, + ] + + const buttons: ModalTemplateButton[] = [ + { + text: t('notification.monad.button.explore'), + onPress: onExplorePress, + isPrimary: true, + }, + ] + + return ( + + ) +} diff --git a/packages/uniswap/src/data/apiClients/liquidityService/useMigrateV2ToV3LPPositionQuery.ts b/packages/uniswap/src/data/apiClients/liquidityService/useMigrateV2ToV3LPPositionQuery.ts new file mode 100644 index 00000000000..45a2f57d614 --- /dev/null +++ b/packages/uniswap/src/data/apiClients/liquidityService/useMigrateV2ToV3LPPositionQuery.ts @@ -0,0 +1,35 @@ +import { UseQueryResult, useQuery } from '@tanstack/react-query' +import { + MigrateV2ToV3LPPositionRequest, + MigrateV2ToV3LPPositionResponse, +} from '@uniswap/client-liquidity/dist/uniswap/liquidity/v1/api_pb' +import { LIQUIDITY_PATHS, type UseQueryApiHelperHookArgs } from '@universe/api' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { LiquidityServiceClient } from 'uniswap/src/data/apiClients/liquidityService/LiquidityServiceClient' +import { ReactQueryCacheKey } from 'utilities/src/reactQuery/cache' + +export function useMigrateV2ToV3LPPositionQuery({ + params, + ...rest +}: UseQueryApiHelperHookArgs< + MigrateV2ToV3LPPositionRequest, + MigrateV2ToV3LPPositionResponse +>): UseQueryResult { + const queryKey = [ + ReactQueryCacheKey.LiquidityService, + uniswapUrls.liquidityServiceUrl, + LIQUIDITY_PATHS.migrateV2ToV3LPPosition, + params, + ] + + return useQuery({ + queryKey, + queryFn: async () => { + if (!params) { + throw { name: 'Params are required' } + } + return await LiquidityServiceClient.migrateV2ToV3LpPosition(params) + }, + ...rest, + }) +} diff --git a/packages/uniswap/src/data/apiClients/liquidityService/useMigrateV3ToV4LPPositionQuery.ts b/packages/uniswap/src/data/apiClients/liquidityService/useMigrateV3ToV4LPPositionQuery.ts new file mode 100644 index 00000000000..7773562e80e --- /dev/null +++ b/packages/uniswap/src/data/apiClients/liquidityService/useMigrateV3ToV4LPPositionQuery.ts @@ -0,0 +1,35 @@ +import { UseQueryResult, useQuery } from '@tanstack/react-query' +import { + MigrateV3ToV4LPPositionRequest, + MigrateV3ToV4LPPositionResponse, +} from '@uniswap/client-liquidity/dist/uniswap/liquidity/v1/api_pb' +import { LIQUIDITY_PATHS, type UseQueryApiHelperHookArgs } from '@universe/api' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { LiquidityServiceClient } from 'uniswap/src/data/apiClients/liquidityService/LiquidityServiceClient' +import { ReactQueryCacheKey } from 'utilities/src/reactQuery/cache' + +export function useMigrateV3ToV4LPPositionQuery({ + params, + ...rest +}: UseQueryApiHelperHookArgs< + MigrateV3ToV4LPPositionRequest, + MigrateV3ToV4LPPositionResponse +>): UseQueryResult { + const queryKey = [ + ReactQueryCacheKey.LiquidityService, + uniswapUrls.liquidityServiceUrl, + LIQUIDITY_PATHS.migrateV3ToV4LPPosition, + params, + ] + + return useQuery({ + queryKey, + queryFn: async () => { + if (!params) { + throw { name: 'Params are required' } + } + return await LiquidityServiceClient.migrateV3ToV4LpPosition(params) + }, + ...rest, + }) +} diff --git a/packages/uniswap/src/data/rest/auctions/auctionService.ts b/packages/uniswap/src/data/rest/auctions/auctionService.ts new file mode 100644 index 00000000000..09530404105 --- /dev/null +++ b/packages/uniswap/src/data/rest/auctions/auctionService.ts @@ -0,0 +1,44 @@ +/** + * TODO | Toucan: Remove these stubs once backend endpoints are available + * Stubbed auction service methods + * + * These are temporary function stubs that throw errors. When the backend generates + * the actual connect-query service, replace this entire file with a single line: + * example: + * export * from '@uniswap/client-data-api/dist/data/v1/api-AuctionService_connectquery' + * + * That will provide the real service methods with proper protobuf Message types. + */ + +import type { + GetAuctionDetailsRequest, + GetAuctionDetailsResponse, + GetAuctionsRequest, + GetAuctionsResponse, + GetBidConcentrationRequest, + GetBidConcentrationResponse, + GetBidsByWalletRequest, + GetBidsByWalletResponse, + GetLatestCheckpointRequest, + GetLatestCheckpointResponse, +} from 'uniswap/src/data/rest/auctions/types' + +export async function getAuctions(_input?: GetAuctionsRequest): Promise { + throw new Error('AuctionService.getAuctions: Not implemented - awaiting backend endpoint') +} + +export async function getBidsByWallet(_input: GetBidsByWalletRequest): Promise { + throw new Error('AuctionService.getBidsByWallet: Not implemented - awaiting backend endpoint') +} + +export async function getBidConcentration(_input: GetBidConcentrationRequest): Promise { + throw new Error('AuctionService.getBidConcentration: Not implemented - awaiting backend endpoint') +} + +export async function getAuctionDetails(_input: GetAuctionDetailsRequest): Promise { + throw new Error('AuctionService.getAuctionDetails: Not implemented - awaiting backend endpoint') +} + +export async function getLatestCheckpoint(_input: GetLatestCheckpointRequest): Promise { + throw new Error('AuctionService.getLatestCheckpoint: Not implemented - awaiting backend endpoint') +} diff --git a/packages/uniswap/src/data/rest/auctions/paths.ts b/packages/uniswap/src/data/rest/auctions/paths.ts new file mode 100644 index 00000000000..f14054e6c12 --- /dev/null +++ b/packages/uniswap/src/data/rest/auctions/paths.ts @@ -0,0 +1,12 @@ +/** + * TODO | Toucan: Update these paths once backend endpoints are finalized + */ +export const AUCTION_API_PATHS = { + getAuctions: '/get-auctions', + getBidsByWallet: '/get-bids-by-wallet', + getBidConcentration: '/get-bid-concentration', + getAuctionDetails: '/get-auction-details', + getLatestCheckpoint: '/get-latest-checkpoint', +} as const + +export type AuctionApiPath = (typeof AUCTION_API_PATHS)[keyof typeof AUCTION_API_PATHS] diff --git a/packages/uniswap/src/data/rest/auctions/types.ts b/packages/uniswap/src/data/rest/auctions/types.ts new file mode 100644 index 00000000000..cb8e071df6a --- /dev/null +++ b/packages/uniswap/src/data/rest/auctions/types.ts @@ -0,0 +1,80 @@ +// TODO | Toucan: remove these types once they can be auto-generated +export interface GetBidsByWalletRequest { + wallet_id: string + auction_id?: string + page_size?: number + page_token?: string +} + +export interface Bid { + bid_id: string + auction_id: string + wallet_id: string + tx_hash: string + tokens_allocated: string + max_price: string + created_at: string + status: string + starting_block: string + base_currency_spent: string + base_currency_initial: string +} + +export interface GetBidsByWalletResponse { + bids: Bid[] + next_page_token?: string +} + +export interface GetBidConcentrationRequest { + auction_id: string +} + +export interface GetBidConcentrationResponse { + /** + * Map of tick_price -> volume + * Represents the distribution of auction bids at all ticks + */ + bid_concentration: Record +} + +export interface Auction { + auction_id: string + chain_id: number + token_symbol: string + token_address: string + token_name: string + creator_address: string + start_block: string + end_block: string + total_supply: string + tick_size: string + graduation_threshold_mps: string + bid_token_address: string +} + +export interface GetAuctionDetailsRequest { + auction_id: string +} + +export interface GetAuctionDetailsResponse { + auction: Auction +} + +export interface GetAuctionsRequest { + page_size?: number + page_token?: string +} + +export interface GetAuctionsResponse { + auctions: Auction[] + next_page_token?: string +} + +export interface GetLatestCheckpointRequest { + auction_id: string +} + +export interface GetLatestCheckpointResponse { + clearing_price: string + cumulative_mps: string +} diff --git a/packages/uniswap/src/data/rest/auctions/useGetAuctionDetailsQuery.ts b/packages/uniswap/src/data/rest/auctions/useGetAuctionDetailsQuery.ts new file mode 100644 index 00000000000..6565b3ad37e --- /dev/null +++ b/packages/uniswap/src/data/rest/auctions/useGetAuctionDetailsQuery.ts @@ -0,0 +1,31 @@ +import { type UseQueryResult, useQuery } from '@tanstack/react-query' +import { getAuctionDetails } from 'uniswap/src/data/rest/auctions/auctionService' +import { AUCTION_API_PATHS } from 'uniswap/src/data/rest/auctions/paths' +import type { GetAuctionDetailsRequest, GetAuctionDetailsResponse } from 'uniswap/src/data/rest/auctions/types' +import { ReactQueryCacheKey } from 'utilities/src/reactQuery/cache' +import { MAX_REACT_QUERY_CACHE_TIME_MS, ONE_SECOND_MS } from 'utilities/src/time/time' + +const STALE_TIME = 5 * 60 * ONE_SECOND_MS + +export function useGetAuctionDetailsQuery({ + input, + enabled = true, + staleTime = STALE_TIME, + gcTime = MAX_REACT_QUERY_CACHE_TIME_MS, + select, +}: { + input: GetAuctionDetailsRequest + enabled?: boolean + staleTime?: number + gcTime?: number + select?: (data: GetAuctionDetailsResponse) => TSelectData +}): UseQueryResult { + return useQuery({ + queryKey: [ReactQueryCacheKey.AuctionApi, AUCTION_API_PATHS.getAuctionDetails, input] as const, + queryFn: async () => getAuctionDetails(input), + enabled, + staleTime, + gcTime, + select, + }) +} diff --git a/packages/uniswap/src/data/rest/auctions/useGetAuctionsQuery.ts b/packages/uniswap/src/data/rest/auctions/useGetAuctionsQuery.ts new file mode 100644 index 00000000000..51d2a5913b6 --- /dev/null +++ b/packages/uniswap/src/data/rest/auctions/useGetAuctionsQuery.ts @@ -0,0 +1,32 @@ +import { keepPreviousData, type UseQueryResult, useQuery } from '@tanstack/react-query' +import { getAuctions } from 'uniswap/src/data/rest/auctions/auctionService' +import { AUCTION_API_PATHS } from 'uniswap/src/data/rest/auctions/paths' +import type { GetAuctionsRequest, GetAuctionsResponse } from 'uniswap/src/data/rest/auctions/types' +import { ReactQueryCacheKey } from 'utilities/src/reactQuery/cache' +import { ONE_SECOND_MS } from 'utilities/src/time/time' + +const STALE_TIME = 60 * ONE_SECOND_MS + +export function useGetAuctionsQuery({ + input, + enabled = true, + staleTime = STALE_TIME, + refetchInterval = false, + select, +}: { + input?: GetAuctionsRequest + enabled?: boolean + staleTime?: number + refetchInterval?: number | false + select?: (data: GetAuctionsResponse) => TSelectData +}): UseQueryResult { + return useQuery({ + queryKey: [ReactQueryCacheKey.AuctionApi, AUCTION_API_PATHS.getAuctions, input] as const, + queryFn: async () => getAuctions(input), + enabled, + staleTime, + refetchInterval, + placeholderData: keepPreviousData, + select, + }) +} diff --git a/packages/uniswap/src/data/rest/auctions/useGetBidConcentrationQuery.ts b/packages/uniswap/src/data/rest/auctions/useGetBidConcentrationQuery.ts new file mode 100644 index 00000000000..b6c356ad746 --- /dev/null +++ b/packages/uniswap/src/data/rest/auctions/useGetBidConcentrationQuery.ts @@ -0,0 +1,31 @@ +import { type UseQueryResult, useQuery } from '@tanstack/react-query' +import { getBidConcentration } from 'uniswap/src/data/rest/auctions/auctionService' +import { AUCTION_API_PATHS } from 'uniswap/src/data/rest/auctions/paths' +import type { GetBidConcentrationRequest, GetBidConcentrationResponse } from 'uniswap/src/data/rest/auctions/types' +import { ReactQueryCacheKey } from 'utilities/src/reactQuery/cache' +import { ONE_SECOND_MS } from 'utilities/src/time/time' + +const STALE_TIME = 30 * ONE_SECOND_MS + +export function useGetBidConcentrationQuery({ + input, + enabled = true, + staleTime = STALE_TIME, + refetchInterval = false, + select, +}: { + input: GetBidConcentrationRequest + enabled?: boolean + staleTime?: number + refetchInterval?: number | false + select?: (data: GetBidConcentrationResponse) => TSelectData +}): UseQueryResult { + return useQuery({ + queryKey: [ReactQueryCacheKey.AuctionApi, AUCTION_API_PATHS.getBidConcentration, input] as const, + queryFn: async () => getBidConcentration(input), + enabled, + staleTime, + refetchInterval, + select, + }) +} diff --git a/packages/uniswap/src/data/rest/auctions/useGetBidsByWalletInfiniteQuery.ts b/packages/uniswap/src/data/rest/auctions/useGetBidsByWalletInfiniteQuery.ts new file mode 100644 index 00000000000..613cb74d66e --- /dev/null +++ b/packages/uniswap/src/data/rest/auctions/useGetBidsByWalletInfiniteQuery.ts @@ -0,0 +1,35 @@ +import { InfiniteData, type UseInfiniteQueryResult, useInfiniteQuery } from '@tanstack/react-query' +import { getBidsByWallet } from 'uniswap/src/data/rest/auctions/auctionService' +import { AUCTION_API_PATHS } from 'uniswap/src/data/rest/auctions/paths' +import type { GetBidsByWalletRequest, GetBidsByWalletResponse } from 'uniswap/src/data/rest/auctions/types' +import { ReactQueryCacheKey } from 'utilities/src/reactQuery/cache' + +export function useGetBidsByWalletInfiniteQuery({ + input, + enabled = true, + staleTime, + refetchInterval = false, +}: { + input: Omit + enabled?: boolean + staleTime?: number + refetchInterval?: number | false +}): UseInfiniteQueryResult, Error> { + return useInfiniteQuery< + GetBidsByWalletResponse, + Error, + InfiniteData, + readonly [ReactQueryCacheKey.AuctionApi, string, Omit, 'infinite'] + >({ + queryKey: [ReactQueryCacheKey.AuctionApi, AUCTION_API_PATHS.getBidsByWallet, input, 'infinite'] as const, + queryFn: async ({ pageParam }): Promise => { + const pageToken = typeof pageParam === 'string' ? pageParam : undefined + return await getBidsByWallet(pageToken ? { ...input, page_token: pageToken } : input) + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage): string | undefined => lastPage.next_page_token || undefined, + enabled, + staleTime, + refetchInterval, + }) +} diff --git a/packages/uniswap/src/data/rest/auctions/useGetBidsByWalletQuery.ts b/packages/uniswap/src/data/rest/auctions/useGetBidsByWalletQuery.ts new file mode 100644 index 00000000000..a836da2c5b2 --- /dev/null +++ b/packages/uniswap/src/data/rest/auctions/useGetBidsByWalletQuery.ts @@ -0,0 +1,32 @@ +import { keepPreviousData, type UseQueryResult, useQuery } from '@tanstack/react-query' +import { getBidsByWallet } from 'uniswap/src/data/rest/auctions/auctionService' +import { AUCTION_API_PATHS } from 'uniswap/src/data/rest/auctions/paths' +import type { GetBidsByWalletRequest, GetBidsByWalletResponse } from 'uniswap/src/data/rest/auctions/types' +import { ReactQueryCacheKey } from 'utilities/src/reactQuery/cache' +import { ONE_SECOND_MS } from 'utilities/src/time/time' + +const STALE_TIME = 30 * ONE_SECOND_MS + +export function useGetBidsByWalletQuery({ + input, + enabled = true, + staleTime = STALE_TIME, + refetchInterval = false, + select, +}: { + input: GetBidsByWalletRequest + enabled?: boolean + staleTime?: number + refetchInterval?: number | false + select?: (data: GetBidsByWalletResponse) => TSelectData +}): UseQueryResult { + return useQuery({ + queryKey: [ReactQueryCacheKey.AuctionApi, AUCTION_API_PATHS.getBidsByWallet, input] as const, + queryFn: async () => getBidsByWallet(input), + enabled, + staleTime, + refetchInterval, + placeholderData: keepPreviousData, + select, + }) +} diff --git a/packages/uniswap/src/data/rest/auctions/useGetLatestCheckpointQuery.ts b/packages/uniswap/src/data/rest/auctions/useGetLatestCheckpointQuery.ts new file mode 100644 index 00000000000..5a4bfee8033 --- /dev/null +++ b/packages/uniswap/src/data/rest/auctions/useGetLatestCheckpointQuery.ts @@ -0,0 +1,31 @@ +import { type UseQueryResult, useQuery } from '@tanstack/react-query' +import { getLatestCheckpoint } from 'uniswap/src/data/rest/auctions/auctionService' +import { AUCTION_API_PATHS } from 'uniswap/src/data/rest/auctions/paths' +import type { GetLatestCheckpointRequest, GetLatestCheckpointResponse } from 'uniswap/src/data/rest/auctions/types' +import { ReactQueryCacheKey } from 'utilities/src/reactQuery/cache' +import { ONE_SECOND_MS } from 'utilities/src/time/time' + +const STALE_TIME = 2 * ONE_SECOND_MS + +export function useGetLatestCheckpointQuery({ + input, + enabled = true, + staleTime = STALE_TIME, + refetchInterval = false, + select, +}: { + input: GetLatestCheckpointRequest + enabled?: boolean + staleTime?: number + refetchInterval?: number | false + select?: (data: GetLatestCheckpointResponse) => TSelectData +}): UseQueryResult { + return useQuery({ + queryKey: [ReactQueryCacheKey.AuctionApi, AUCTION_API_PATHS.getLatestCheckpoint, input] as const, + queryFn: async () => getLatestCheckpoint(input), + enabled, + staleTime, + refetchInterval, + select, + }) +} diff --git a/packages/uniswap/src/features/search/SearchModal/SearchModalNoQueryList.tsx b/packages/uniswap/src/features/search/SearchModal/SearchModalNoQueryList.tsx index ece6ce6b037..ef2614411ad 100644 --- a/packages/uniswap/src/features/search/SearchModal/SearchModalNoQueryList.tsx +++ b/packages/uniswap/src/features/search/SearchModal/SearchModalNoQueryList.tsx @@ -36,7 +36,15 @@ export const SearchModalNoQueryList = memo(function SearchModalNoQueryListInner( }: SearchModalNoQueryListProps): JSX.Element { const { t } = useTranslation() - const { data: sections, loading, error, refetch } = useSectionsForNoQuerySearch({ chainFilter, activeTab }) + const { + data: sections, + loading, + error, + refetch, + } = useSectionsForNoQuerySearch({ + chainFilter, + activeTab, + }) // Handle empty pretype cases for assets without default results const getEmptyElementComponent = (): JSX.Element | undefined => { diff --git a/packages/uniswap/src/features/search/SearchModal/hooks/useWebSearchTabs.ts b/packages/uniswap/src/features/search/SearchModal/hooks/useWebSearchTabs.ts new file mode 100644 index 00000000000..fddf64c6414 --- /dev/null +++ b/packages/uniswap/src/features/search/SearchModal/hooks/useWebSearchTabs.ts @@ -0,0 +1,7 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { SearchTab, WEB_SEARCH_TABS, WEB_SEARCH_TABS_WITH_WALLETS } from 'uniswap/src/features/search/SearchModal/types' + +export function useWebSearchTabs(): SearchTab[] { + const walletSearchEnabled = useFeatureFlag(FeatureFlags.ViewExternalWalletsOnWeb) + return walletSearchEnabled ? WEB_SEARCH_TABS_WITH_WALLETS : WEB_SEARCH_TABS +} diff --git a/packages/uniswap/src/features/transactions/swap/hooks/useSwapWarnings/getAztecUnavailableWarning.ts b/packages/uniswap/src/features/transactions/swap/hooks/useSwapWarnings/getAztecUnavailableWarning.ts new file mode 100644 index 00000000000..ae70c0afbd5 --- /dev/null +++ b/packages/uniswap/src/features/transactions/swap/hooks/useSwapWarnings/getAztecUnavailableWarning.ts @@ -0,0 +1,47 @@ +import { TFunction } from 'i18next' +import { GeneratedIcon } from 'ui/src' +import { Warning, WarningAction, WarningLabel, WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' +import WarningIcon from 'uniswap/src/components/warnings/WarningIcon' +import { DerivedSwapInfo } from 'uniswap/src/features/transactions/swap/types/derivedSwapInfo' +import { shouldShowAztecWarning } from 'uniswap/src/hooks/useShouldShowAztecWarning' +import { CurrencyField } from 'uniswap/src/types/currency' + +export const AZTEC_ADDRESS = '0xA27EC0006e59f245217Ff08CD52A7E8b169E62D2'.toLowerCase() +export const AZTEC_URL = 'https://sale.aztec.network/auction' + +export function getAztecUnavailableWarning({ + t, + currencies, + isAztecDisabled, +}: { + t: TFunction + currencies: DerivedSwapInfo['currencies'] + isAztecDisabled: boolean +}): Warning | undefined { + if (!isAztecDisabled) { + return undefined + } + const inputCurrency = currencies[CurrencyField.INPUT]?.currency + const outputCurrency = currencies[CurrencyField.OUTPUT]?.currency + + const inputTokenAddress = inputCurrency?.isToken ? inputCurrency.address : '' + const outputTokenAddress = outputCurrency?.isToken ? outputCurrency.address : '' + + const isAztecSelected = + shouldShowAztecWarning({ address: inputTokenAddress, isAztecDisabled }) || + shouldShowAztecWarning({ address: outputTokenAddress, isAztecDisabled }) + + if (!isAztecSelected) { + return undefined + } + + return { + type: WarningLabel.AztecUnavailable, + severity: WarningSeverity.Blocked, + action: WarningAction.DisableReview, + icon: WarningIcon as GeneratedIcon, + title: t('swap.warning.aztecUnavailable.title'), + message: t('swap.warning.aztecUnavailable.message'), + link: AZTEC_URL, + } +} diff --git a/packages/uniswap/src/features/transactions/swap/hooks/useSwapWarnings/useSwapWarnings.tsx b/packages/uniswap/src/features/transactions/swap/hooks/useSwapWarnings/useSwapWarnings.tsx index c1a77706759..f3699d17a0c 100644 --- a/packages/uniswap/src/features/transactions/swap/hooks/useSwapWarnings/useSwapWarnings.tsx +++ b/packages/uniswap/src/features/transactions/swap/hooks/useSwapWarnings/useSwapWarnings.tsx @@ -1,3 +1,4 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import type { TFunction } from 'i18next' import isEqual from 'lodash/isEqual' import { useMemo } from 'react' @@ -11,6 +12,7 @@ import { getNetworkWarning, useFormattedWarnings, } from 'uniswap/src/features/transactions/hooks/useParsedTransactionWarnings' +import { getAztecUnavailableWarning } from 'uniswap/src/features/transactions/swap/hooks/useSwapWarnings/getAztecUnavailableWarning' import { getBalanceWarning } from 'uniswap/src/features/transactions/swap/hooks/useSwapWarnings/getBalanceWarning' import { getFormIncompleteWarning } from 'uniswap/src/features/transactions/swap/hooks/useSwapWarnings/getFormIncompleteWarning' import { getPriceImpactWarning } from 'uniswap/src/features/transactions/swap/hooks/useSwapWarnings/getPriceImpactWarning' @@ -31,11 +33,13 @@ export function getSwapWarnings({ formatPercent, derivedSwapInfo, offline, + aztecDisabled = false, }: { t: TFunction formatPercent: LocalizationContextState['formatPercent'] derivedSwapInfo: DerivedSwapInfo offline: boolean + aztecDisabled?: boolean }): Warning[] { const warnings: Warning[] = [] @@ -45,6 +49,15 @@ export function getSwapWarnings({ const { trade } = derivedSwapInfo + const aztecUnavailableWarning = getAztecUnavailableWarning({ + t, + currencies: derivedSwapInfo.currencies, + isAztecDisabled: aztecDisabled, + }) + if (aztecUnavailableWarning) { + warnings.push(aztecUnavailableWarning) + } + // token is blocked const tokenBlockedWarning = getTokenBlockedWarning(t, derivedSwapInfo.currencies) if (tokenBlockedWarning) { @@ -89,8 +102,9 @@ function useSwapWarnings(derivedSwapInfo: DerivedSwapInfo): Warning[] { const { t } = useTranslation() const { formatPercent } = useLocalizationContext() const offline = useIsOffline() + const aztecDisabled = useFeatureFlag(FeatureFlags.DisableAztecToken) - return useMemoCompare(() => getSwapWarnings({ t, formatPercent, derivedSwapInfo, offline }), isEqual) + return useMemoCompare(() => getSwapWarnings({ t, formatPercent, derivedSwapInfo, offline, aztecDisabled }), isEqual) } function useParsedSwapFormWarnings(): ParsedWarnings { diff --git a/packages/uniswap/src/features/transactions/swap/hooks/useTrade.ts b/packages/uniswap/src/features/transactions/swap/hooks/useTrade.ts index 7a8b55d388c..e4106bcaf24 100644 --- a/packages/uniswap/src/features/transactions/swap/hooks/useTrade.ts +++ b/packages/uniswap/src/features/transactions/swap/hooks/useTrade.ts @@ -52,6 +52,7 @@ function parseTradeResult(input: { isIndicativeLoading: indicative.isLoading, error, gasEstimate: data?.gasEstimate, + quoteHash: data?.quoteHash, } } @@ -66,6 +67,7 @@ function parseTradeResult(input: { isIndicativeLoading: false, error: new Error('Unable to validate trade'), gasEstimate: data.gasEstimate, + quoteHash: data.quoteHash, } } @@ -77,5 +79,6 @@ function parseTradeResult(input: { isIndicativeLoading: indicative.isLoading, error, gasEstimate: data.gasEstimate, + quoteHash: data.quoteHash, } } diff --git a/packages/uniswap/src/features/transactions/swap/stores/swapTxStore/SwapTxStoreContextProvider.tsx b/packages/uniswap/src/features/transactions/swap/stores/swapTxStore/SwapTxStoreContextProvider.tsx index 4a1c8070467..533b4317fa8 100644 --- a/packages/uniswap/src/features/transactions/swap/stores/swapTxStore/SwapTxStoreContextProvider.tsx +++ b/packages/uniswap/src/features/transactions/swap/stores/swapTxStore/SwapTxStoreContextProvider.tsx @@ -1,3 +1,4 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { useEffect, useState } from 'react' import { useSwapTxAndGasInfo as useServiceBasedSwapTxAndGasInfo } from 'uniswap/src/features/transactions/swap/review/services/swapTxAndGasInfoService/hooks' import { createSwapTxStore } from 'uniswap/src/features/transactions/swap/stores/swapTxStore/createSwapTxStore' diff --git a/packages/uniswap/src/features/transactions/swap/utils/trade.ts b/packages/uniswap/src/features/transactions/swap/utils/trade.ts index ea5125941e5..2a8b4c83f79 100644 --- a/packages/uniswap/src/features/transactions/swap/utils/trade.ts +++ b/packages/uniswap/src/features/transactions/swap/utils/trade.ts @@ -216,6 +216,12 @@ export function validateTransactionRequest( return undefined } +export function validateTransactionRequestTypeGuard( + request?: providers.TransactionRequest | null, +): request is ValidatedTransactionRequest { + return !!request?.to && !!request.chainId +} + export function validateTransactionRequests( requests?: TransactionRequest[] | null, ): PopulatedTransactionRequestArray | undefined { diff --git a/packages/uniswap/src/features/transactions/swap/utils/tradingApi.ts b/packages/uniswap/src/features/transactions/swap/utils/tradingApi.ts index a815e47a599..d65bd176678 100644 --- a/packages/uniswap/src/features/transactions/swap/utils/tradingApi.ts +++ b/packages/uniswap/src/features/transactions/swap/utils/tradingApi.ts @@ -555,7 +555,8 @@ export function createGetQuoteSlippageParams(ctx: { } // Otherwise, use an auto slippage tolerance calculated on the backend - return { autoSlippage: TradingApi.AutoSlippage.DEFAULT } + // TODO: TradingApi.AutoSlippage.DEFAULT was removed. Verify if there is a replacement. + return { autoSlippage: 'DEFAULT' } } } diff --git a/packages/uniswap/src/features/visibility/visibility.test.ts b/packages/uniswap/src/features/visibility/visibility.test.ts index ed0cbd811e2..b5063e0405b 100644 --- a/packages/uniswap/src/features/visibility/visibility.test.ts +++ b/packages/uniswap/src/features/visibility/visibility.test.ts @@ -30,6 +30,13 @@ const makeEmptyVisibilityState = (): VisibilityState => ({ activity: {}, }) +const makeEmptyVisibilityState = (): VisibilityState => ({ + positions: {}, + tokens: {}, + nfts: {}, + activity: {}, +}) + describe('visibility slice', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/packages/uniswap/src/hooks/useShouldShowAztecWarning.ts b/packages/uniswap/src/hooks/useShouldShowAztecWarning.ts new file mode 100644 index 00000000000..3a2e82231c7 --- /dev/null +++ b/packages/uniswap/src/hooks/useShouldShowAztecWarning.ts @@ -0,0 +1,18 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { AZTEC_ADDRESS } from 'uniswap/src/features/transactions/swap/hooks/useSwapWarnings/getAztecUnavailableWarning' + +export const shouldShowAztecWarning = ({ + address, + isAztecDisabled, +}: { + address: Address + isAztecDisabled: boolean +}): boolean => { + const isAztec = address.toLowerCase() === AZTEC_ADDRESS + return isAztec && isAztecDisabled +} + +export const useShouldShowAztecWarning = (address: Address): boolean => { + const isAztecDisabled = useFeatureFlag(FeatureFlags.DisableAztecToken) + return shouldShowAztecWarning({ address, isAztecDisabled }) +} diff --git a/packages/uniswap/src/utils/datadog.web.ts b/packages/uniswap/src/utils/datadog.web.ts index 41b3ec4f9e0..100e9ac3513 100644 --- a/packages/uniswap/src/utils/datadog.web.ts +++ b/packages/uniswap/src/utils/datadog.web.ts @@ -67,25 +67,34 @@ function beforeSend(event: RumEvent, context: RumEventDomainContext): boolean { } if (event.type === 'resource' && event.resource.url.includes('gateway.uniswap.org')) { - const requestHeaders = (context as RumFetchResourceEventDomainContext).requestInit?.headers - if (requestHeaders) { - const headersRecord = - requestHeaders instanceof Headers - ? Object.fromEntries(requestHeaders.entries()) - : Array.isArray(requestHeaders) - ? Object.fromEntries(requestHeaders) - : requestHeaders - const tradingApiHeaderValues = new Set(Object.values(TradingApiHeaders)) - const featureFlagHeaders: Record = {} - for (const [key, value] of Object.entries(headersRecord)) { - if (tradingApiHeaderValues.has(key)) { - featureFlagHeaders[key] = String(value) + let isGatewayUniswapRequest = false + try { + isGatewayUniswapRequest = new URL(event.resource.url).hostname === 'gateway.uniswap.org' + } catch { + // ignore invalid URLs + } + + if (isGatewayUniswapRequest) { + const requestHeaders = (context as RumFetchResourceEventDomainContext).requestInit?.headers + if (requestHeaders) { + const headersRecord = + requestHeaders instanceof Headers + ? Object.fromEntries(requestHeaders.entries()) + : Array.isArray(requestHeaders) + ? Object.fromEntries(requestHeaders) + : requestHeaders + const tradingApiHeaderValues = new Set(Object.values(TradingApiHeaders)) + const featureFlagHeaders: Record = {} + for (const [key, value] of Object.entries(headersRecord)) { + if (tradingApiHeaderValues.has(key)) { + featureFlagHeaders[key] = String(value) + } } - } - if (Object.keys(featureFlagHeaders).length > 0) { - event.context = { - ...event.context, - tradingApiHeaders: featureFlagHeaders, + if (Object.keys(featureFlagHeaders).length > 0) { + event.context = { + ...event.context, + tradingApiHeaders: featureFlagHeaders, + } } } } diff --git a/packages/wallet/package.json b/packages/wallet/package.json index 922a777234a..8615fa624a6 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -46,7 +46,7 @@ "graphql": "16.10.0", "i18next": "23.10.0", "jsbi": "3.2.5", - "lodash": "4.17.23", + "lodash": "4.18.1", "mockdate": "3.0.5", "no-yolo-signatures": "0.0.2", "react": "19.0.3", diff --git a/packages/wallet/src/components/dappRequests/AccountSelectPopover.tsx b/packages/wallet/src/components/dappRequests/AccountSelectPopover.tsx index 2b4cbc06233..50a6d9ea2f2 100644 --- a/packages/wallet/src/components/dappRequests/AccountSelectPopover.tsx +++ b/packages/wallet/src/components/dappRequests/AccountSelectPopover.tsx @@ -92,6 +92,7 @@ export function AccountSelectPopover({ + diff --git a/packages/wallet/src/features/transactions/watcher/watchOnChainTransactionSaga.ts b/packages/wallet/src/features/transactions/watcher/watchOnChainTransactionSaga.ts index dcaaa483404..b2ef4e78efe 100644 --- a/packages/wallet/src/features/transactions/watcher/watchOnChainTransactionSaga.ts +++ b/packages/wallet/src/features/transactions/watcher/watchOnChainTransactionSaga.ts @@ -1,4 +1,5 @@ import { ApolloClient, NormalizedCacheObject } from '@apollo/client' +import { FeatureFlags, getFeatureFlagName, getStatsigClient } from '@universe/gating' import { BigNumber, BigNumberish, providers } from 'ethers' import { call, cancel, delay, fork, put, race, spawn, take } from 'typed-redux-saga' import { UniverseChainId } from 'uniswap/src/features/chains/types'