diff --git a/.github/workflows/linear-triage.lock.yml b/.github/workflows/linear-triage.lock.yml deleted file mode 100644 index b88d6c6922b..00000000000 --- a/.github/workflows/linear-triage.lock.yml +++ /dev/null @@ -1,1141 +0,0 @@ -# -# ___ _ _ -# / _ \ | | (_) -# | |_| | __ _ ___ _ __ | |_ _ ___ -# | _ |/ _` |/ _ \ '_ \| __| |/ __| -# | | | | (_| | __/ | | | |_| | (__ -# \_| |_/\__, |\___|_| |_|\__|_|\___| -# __/ | -# _ _ |___/ -# | | | | / _| | -# | | | | ___ _ __ _ __| |_| | _____ ____ -# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| -# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ -# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ -# -# This file was automatically generated by gh-aw (v0.51.5). DO NOT EDIT. -# -# To update this file, edit the corresponding .md file and run: -# gh aw compile -# Not all edits will cause changes to this file. -# -# For more information: https://github.github.com/gh-aw/introduction/overview/ -# -# Triage new Linear issues for the Berlin Bureau (BER) team — classify type, assign priority, tag product area, and post reasoning comments. -# -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"c42f30d0477c02ecc7ccee1daa2da631c7f63302d41e2d4beb79839fcddcaca2","compiler_version":"v0.51.5"} - -name: "Linear Issue Triage Agent" -"on": - schedule: - - cron: "17 16 * * 1-5" - # Friendly format: daily on weekdays (scattered) - workflow_dispatch: - -permissions: {} - -concurrency: - group: "gh-aw-${{ github.workflow }}" - -run-name: "Linear Issue Triage Agent" - -jobs: - activation: - if: github.repository == 'TryGhost/Ghost' - runs-on: ubuntu-slim - permissions: - contents: read - outputs: - comment_id: "" - comment_repo: "" - model: ${{ steps.generate_aw_info.outputs.model }} - secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@ce1794953e0ec42adc41b6fca05e02ab49ee21c3 # v0.68.3 - with: - destination: /opt/gh-aw/actions - - name: Generate agentic run info - id: generate_aw_info - env: - GH_AW_INFO_ENGINE_ID: "copilot" - GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" - GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} - GH_AW_INFO_VERSION: "" - GH_AW_INFO_AGENT_VERSION: "0.0.420" - GH_AW_INFO_CLI_VERSION: "v0.51.5" - GH_AW_INFO_WORKFLOW_NAME: "Linear Issue Triage Agent" - GH_AW_INFO_EXPERIMENTAL: "false" - GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" - GH_AW_INFO_STAGED: "false" - GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","node","mcp.linear.app"]' - GH_AW_INFO_FIREWALL_ENABLED: "true" - GH_AW_INFO_AWF_VERSION: "v0.23.0" - GH_AW_INFO_AWMG_VERSION: "" - GH_AW_INFO_FIREWALL_TYPE: "squid" - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { main } = require('/opt/gh-aw/actions/generate_aw_info.cjs'); - await main(core, context); - - name: Validate COPILOT_GITHUB_TOKEN secret - id: validate-secret - run: /opt/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default - env: - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - - name: Checkout .github and .agents folders - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - sparse-checkout: | - .github - .agents - sparse-checkout-cone-mode: true - fetch-depth: 1 - persist-credentials: false - - name: Check workflow file timestamps - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_WORKFLOW_FILE: "linear-triage.lock.yml" - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs'); - await main(); - - name: Create prompt with built-in context - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_GITHUB_ACTOR: ${{ github.actor }} - GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} - GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} - GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} - GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - run: | - bash /opt/gh-aw/actions/create_prompt_first.sh - { - cat << 'GH_AW_PROMPT_EOF' - - GH_AW_PROMPT_EOF - cat "/opt/gh-aw/prompts/xpia.md" - cat "/opt/gh-aw/prompts/temp_folder_prompt.md" - cat "/opt/gh-aw/prompts/markdown.md" - cat "/opt/gh-aw/prompts/cache_memory_prompt.md" - cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_EOF' - - Tools: create_issue, missing_tool, missing_data, noop - - - The following GitHub context information is available for this workflow: - {{#if __GH_AW_GITHUB_ACTOR__ }} - - **actor**: __GH_AW_GITHUB_ACTOR__ - {{/if}} - {{#if __GH_AW_GITHUB_REPOSITORY__ }} - - **repository**: __GH_AW_GITHUB_REPOSITORY__ - {{/if}} - {{#if __GH_AW_GITHUB_WORKSPACE__ }} - - **workspace**: __GH_AW_GITHUB_WORKSPACE__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} - - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} - - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} - - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} - - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ - {{/if}} - {{#if __GH_AW_GITHUB_RUN_ID__ }} - - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ - {{/if}} - - - GH_AW_PROMPT_EOF - cat << 'GH_AW_PROMPT_EOF' - - GH_AW_PROMPT_EOF - cat << 'GH_AW_PROMPT_EOF' - {{#runtime-import .github/workflows/linear-triage.md}} - GH_AW_PROMPT_EOF - } > "$GH_AW_PROMPT" - - name: Interpolate variables and render templates - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); - await main(); - - name: Substitute placeholders - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_ALLOWED_EXTENSIONS: '' - GH_AW_CACHE_DESCRIPTION: '' - GH_AW_CACHE_DIR: '/tmp/gh-aw/cache-memory/' - GH_AW_GITHUB_ACTOR: ${{ github.actor }} - GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} - GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} - GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} - GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - - const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); - - // Call the substitution function - return await substitutePlaceholders({ - file: process.env.GH_AW_PROMPT, - substitutions: { - GH_AW_ALLOWED_EXTENSIONS: process.env.GH_AW_ALLOWED_EXTENSIONS, - GH_AW_CACHE_DESCRIPTION: process.env.GH_AW_CACHE_DESCRIPTION, - GH_AW_CACHE_DIR: process.env.GH_AW_CACHE_DIR, - GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, - GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, - GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, - GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, - GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE - } - }); - - name: Validate prompt placeholders - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh - - name: Print prompt - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash /opt/gh-aw/actions/print_prompt_summary.sh - - name: Upload activation artifact - if: success() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: activation - path: | - /tmp/gh-aw/aw_info.json - /tmp/gh-aw/aw-prompts/prompt.txt - retention-days: 1 - - agent: - needs: activation - runs-on: ubuntu-latest - permissions: - contents: read - concurrency: - group: "gh-aw-copilot-${{ github.workflow }}" - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - GH_AW_ASSETS_ALLOWED_EXTS: "" - GH_AW_ASSETS_BRANCH: "" - GH_AW_ASSETS_MAX_SIZE_KB: 0 - GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs - GH_AW_SAFE_OUTPUTS: /opt/gh-aw/safeoutputs/outputs.jsonl - GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json - GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json - GH_AW_WORKFLOW_ID_SANITIZED: lineartriage - outputs: - checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} - detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} - detection_success: ${{ steps.detection_conclusion.outputs.success }} - has_patch: ${{ steps.collect_output.outputs.has_patch }} - model: ${{ needs.activation.outputs.model }} - output: ${{ steps.collect_output.outputs.output }} - output_types: ${{ steps.collect_output.outputs.output_types }} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@ce1794953e0ec42adc41b6fca05e02ab49ee21c3 # v0.68.3 - with: - destination: /opt/gh-aw/actions - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Setup Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - with: - node-version: '24' - package-manager-cache: false - - name: Create gh-aw temp directory - run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh - # Cache memory file share configuration from frontmatter processed below - - name: Create cache-memory directory - run: bash /opt/gh-aw/actions/create_cache_memory_dir.sh - - name: Restore cache-memory file share data - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - key: memory-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ github.run_id }} - path: /tmp/gh-aw/cache-memory - restore-keys: | - memory-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}- - - name: Configure Git credentials - env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - git config --global am.keepcr true - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" - - name: Checkout PR branch - id: checkout-pr - if: | - (github.event.pull_request) || (github.event.issue.pull_request) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/checkout_pr_branch.cjs'); - await main(); - - name: Install GitHub Copilot CLI - run: /opt/gh-aw/actions/install_copilot_cli.sh 0.0.420 - - name: Install awf binary - run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.23.0 - - name: Determine automatic lockdown mode for GitHub MCP Server - id: determine-automatic-lockdown - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} - GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - with: - script: | - const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); - await determineAutomaticLockdown(github, context, core); - - name: Download container images - run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.23.0 ghcr.io/github/gh-aw-firewall/api-proxy:0.23.0 ghcr.io/github/gh-aw-firewall/squid:0.23.0 ghcr.io/github/gh-aw-mcpg:v0.1.6 ghcr.io/github/github-mcp-server:v0.31.0 node:lts-alpine - - name: Write Safe Outputs Config - run: | - mkdir -p /opt/gh-aw/safeoutputs - mkdir -p /tmp/gh-aw/safeoutputs - mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > /opt/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF' - {"create_issue":{"max":1},"missing_data":{},"missing_tool":{},"noop":{"max":1}} - GH_AW_SAFE_OUTPUTS_CONFIG_EOF - cat > /opt/gh-aw/safeoutputs/tools.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_EOF' - [ - { - "description": "Create a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead. CONSTRAINTS: Maximum 1 issue(s) can be created.", - "inputSchema": { - "additionalProperties": false, - "properties": { - "body": { - "description": "Detailed issue description in Markdown. Do NOT repeat the title as a heading since it already appears as the issue's h1. Include context, reproduction steps, or acceptance criteria as appropriate.", - "type": "string" - }, - "labels": { - "description": "Labels to categorize the issue (e.g., 'bug', 'enhancement'). Labels must exist in the repository.", - "items": { - "type": "string" - }, - "type": "array" - }, - "parent": { - "description": "Parent issue number for creating sub-issues. This is the numeric ID from the GitHub URL (e.g., 42 in github.com/owner/repo/issues/42). Can also be a temporary_id (e.g., 'aw_abc123', 'aw_Test123') from a previously created issue in the same workflow run.", - "type": [ - "number", - "string" - ] - }, - "temporary_id": { - "description": "Unique temporary identifier for referencing this issue before it's created. Format: 'aw_' followed by 3 to 8 alphanumeric characters (e.g., 'aw_abc1', 'aw_Test123'). Use '#aw_ID' in body text to reference other issues by their temporary_id; these are replaced with actual issue numbers after creation.", - "pattern": "^aw_[A-Za-z0-9]{3,8}$", - "type": "string" - }, - "title": { - "description": "Concise issue title summarizing the bug, feature, or task. The title appears as the main heading, so keep it brief and descriptive.", - "type": "string" - } - }, - "required": [ - "title", - "body" - ], - "type": "object" - }, - "name": "create_issue" - }, - { - "description": "Report that a tool or capability needed to complete the task is not available, or share any information you deem important about missing functionality or limitations. Use this when you cannot accomplish what was requested because the required functionality is missing or access is restricted.", - "inputSchema": { - "additionalProperties": false, - "properties": { - "alternatives": { - "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", - "type": "string" - }, - "reason": { - "description": "Explanation of why this tool is needed or what information you want to share about the limitation (max 256 characters).", - "type": "string" - }, - "tool": { - "description": "Optional: Name or description of the missing tool or capability (max 128 characters). Be specific about what functionality is needed.", - "type": "string" - } - }, - "required": [ - "reason" - ], - "type": "object" - }, - "name": "missing_tool" - }, - { - "description": "Log a transparency message when no significant actions are needed. Use this to confirm workflow completion and provide visibility when analysis is complete but no changes or outputs are required (e.g., 'No issues found', 'All checks passed'). This ensures the workflow produces human-visible output even when no other actions are taken.", - "inputSchema": { - "additionalProperties": false, - "properties": { - "message": { - "description": "Status or completion message to log. Should explain what was analyzed and the outcome (e.g., 'Code review complete - no issues found', 'Analysis complete - all tests passing').", - "type": "string" - } - }, - "required": [ - "message" - ], - "type": "object" - }, - "name": "noop" - }, - { - "description": "Report that data or information needed to complete the task is not available. Use this when you cannot accomplish what was requested because required data, context, or information is missing.", - "inputSchema": { - "additionalProperties": false, - "properties": { - "alternatives": { - "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", - "type": "string" - }, - "context": { - "description": "Additional context about the missing data or where it should come from (max 256 characters).", - "type": "string" - }, - "data_type": { - "description": "Type or description of the missing data or information (max 128 characters). Be specific about what data is needed.", - "type": "string" - }, - "reason": { - "description": "Explanation of why this data is needed to complete the task (max 256 characters).", - "type": "string" - } - }, - "required": [], - "type": "object" - }, - "name": "missing_data" - } - ] - GH_AW_SAFE_OUTPUTS_TOOLS_EOF - cat > /opt/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_EOF' - { - "create_issue": { - "defaultMax": 1, - "fields": { - "body": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 - }, - "labels": { - "type": "array", - "itemType": "string", - "itemSanitize": true, - "itemMaxLength": 128 - }, - "parent": { - "issueOrPRNumber": true - }, - "repo": { - "type": "string", - "maxLength": 256 - }, - "temporary_id": { - "type": "string" - }, - "title": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 128 - } - } - }, - "missing_data": { - "defaultMax": 20, - "fields": { - "alternatives": { - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "context": { - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "data_type": { - "type": "string", - "sanitize": true, - "maxLength": 128 - }, - "reason": { - "type": "string", - "sanitize": true, - "maxLength": 256 - } - } - }, - "missing_tool": { - "defaultMax": 20, - "fields": { - "alternatives": { - "type": "string", - "sanitize": true, - "maxLength": 512 - }, - "reason": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "tool": { - "type": "string", - "sanitize": true, - "maxLength": 128 - } - } - }, - "noop": { - "defaultMax": 1, - "fields": { - "message": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 - } - } - } - } - GH_AW_SAFE_OUTPUTS_VALIDATION_EOF - - name: Generate Safe Outputs MCP Server Config - id: safe-outputs-config - run: | - # Generate a secure random API key (360 bits of entropy, 40+ chars) - # Mask immediately to prevent timing vulnerabilities - API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - echo "::add-mask::${API_KEY}" - - PORT=3001 - - # Set outputs for next steps - { - echo "safe_outputs_api_key=${API_KEY}" - echo "safe_outputs_port=${PORT}" - } >> "$GITHUB_OUTPUT" - - echo "Safe Outputs MCP server will run on port ${PORT}" - - - name: Start Safe Outputs MCP HTTP Server - id: safe-outputs-start - env: - DEBUG: '*' - GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} - GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} - GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json - GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json - GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs - run: | - # Environment variables are set above to prevent template injection - export DEBUG - export GH_AW_SAFE_OUTPUTS_PORT - export GH_AW_SAFE_OUTPUTS_API_KEY - export GH_AW_SAFE_OUTPUTS_TOOLS_PATH - export GH_AW_SAFE_OUTPUTS_CONFIG_PATH - export GH_AW_MCP_LOG_DIR - - bash /opt/gh-aw/actions/start_safe_outputs_server.sh - - - name: Start MCP Gateway - id: start-mcp-gateway - env: - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} - GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} - GITHUB_MCP_LOCKDOWN: ${{ steps.determine-automatic-lockdown.outputs.lockdown == 'true' && '1' || '0' }} - GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} - run: | - set -eo pipefail - mkdir -p /tmp/gh-aw/mcp-config - - # Export gateway environment variables for MCP config and gateway script - export MCP_GATEWAY_PORT="80" - export MCP_GATEWAY_DOMAIN="host.docker.internal" - MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - echo "::add-mask::${MCP_GATEWAY_API_KEY}" - export MCP_GATEWAY_API_KEY - export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" - mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" - export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" - export DEBUG="*" - - export GH_AW_ENGINE="copilot" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -e LINEAR_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.6' - - mkdir -p /home/runner/.copilot - cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh - { - "mcpServers": { - "github": { - "type": "stdio", - "container": "ghcr.io/github/github-mcp-server:v0.31.0", - "env": { - "GITHUB_LOCKDOWN_MODE": "$GITHUB_MCP_LOCKDOWN", - "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", - "GITHUB_READ_ONLY": "1", - "GITHUB_TOOLSETS": "context,repos,issues,pull_requests" - } - }, - "linear": { - "type": "stdio", - "container": "node:lts-alpine", - "entrypoint": "npx", - "entrypointArgs": [ - "npx", - "-y", - "mcp-remote", - "https://mcp.linear.app/mcp", - "--header", - "Authorization:Bearer \${LINEAR_API_KEY}" - ], - "tools": [ - "*" - ], - "env": { - "LINEAR_API_KEY": "\${LINEAR_API_KEY}" - } - }, - "safeoutputs": { - "type": "http", - "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT", - "headers": { - "Authorization": "\${GH_AW_SAFE_OUTPUTS_API_KEY}" - } - } - }, - "gateway": { - "port": $MCP_GATEWAY_PORT, - "domain": "${MCP_GATEWAY_DOMAIN}", - "apiKey": "${MCP_GATEWAY_API_KEY}", - "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" - } - } - GH_AW_MCP_CONFIG_EOF - - name: Download activation artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: activation - path: /tmp/gh-aw - - name: Clean git credentials - run: bash /opt/gh-aw/actions/clean_git_credentials.sh - - name: Execute GitHub Copilot CLI - id: agentic_execution - # Copilot CLI tool arguments (sorted): - timeout-minutes: 20 - run: | - set -o pipefail - # shellcheck disable=SC1003 - sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains "*.jsr.io,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,bun.sh,cdn.jsdelivr.net,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,deb.nodesource.com,deno.land,esm.sh,get.pnpm.io,github.com,googleapis.deno.dev,googlechromelabs.github.io,host.docker.internal,json-schema.org,json.schemastore.org,jsr.io,keyserver.ubuntu.com,mcp.linear.app,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.bower.io,registry.npmjs.com,registry.npmjs.org,registry.yarnpkg.com,repo.yarnpkg.com,s.symcb.com,s.symcd.com,security.ubuntu.com,skimdb.npmjs.com,storage.googleapis.com,telemetry.enterprise.githubcopilot.com,telemetry.vercel.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.npmjs.com,www.npmjs.org,yarnpkg.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.23.0 --skip-pull --enable-api-proxy \ - -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --add-dir /tmp/gh-aw/cache-memory/ --allow-all-paths --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_COPILOT:+ --model "$GH_AW_MODEL_AGENT_COPILOT"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log - env: - COPILOT_AGENT_RUNNER_TYPE: STANDALONE - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json - GH_AW_MODEL_AGENT_COPILOT: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GITHUB_API_URL: ${{ github.api_url }} - GITHUB_HEAD_REF: ${{ github.head_ref }} - GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - GITHUB_REF_NAME: ${{ github.ref_name }} - GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} - GITHUB_WORKSPACE: ${{ github.workspace }} - XDG_CONFIG_HOME: /home/runner - - name: Configure Git credentials - env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - git config --global am.keepcr true - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" - - name: Copy Copilot session state files to logs - if: always() - continue-on-error: true - run: | - # Copy Copilot session state files to logs folder for artifact collection - # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them - SESSION_STATE_DIR="$HOME/.copilot/session-state" - LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" - - if [ -d "$SESSION_STATE_DIR" ]; then - echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" - mkdir -p "$LOGS_DIR" - cp -v "$SESSION_STATE_DIR"/*.jsonl "$LOGS_DIR/" 2>/dev/null || true - echo "Session state files copied successfully" - else - echo "No session-state directory found at $SESSION_STATE_DIR" - fi - - name: Stop MCP Gateway - if: always() - continue-on-error: true - env: - MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} - MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} - GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} - run: | - bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" - - name: Redact secrets in logs - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/redact_secrets.cjs'); - await main(); - env: - GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN,LINEAR_API_KEY' - SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} - SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SECRET_LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} - - name: Upload Safe Outputs - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: safe-output - path: ${{ env.GH_AW_SAFE_OUTPUTS }} - if-no-files-found: warn - - name: Ingest agent output - id: collect_output - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_ALLOWED_DOMAINS: "*.jsr.io,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,bun.sh,cdn.jsdelivr.net,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,deb.nodesource.com,deno.land,esm.sh,get.pnpm.io,github.com,googleapis.deno.dev,googlechromelabs.github.io,host.docker.internal,json-schema.org,json.schemastore.org,jsr.io,keyserver.ubuntu.com,mcp.linear.app,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.bower.io,registry.npmjs.com,registry.npmjs.org,registry.yarnpkg.com,repo.yarnpkg.com,s.symcb.com,s.symcd.com,security.ubuntu.com,skimdb.npmjs.com,storage.googleapis.com,telemetry.enterprise.githubcopilot.com,telemetry.vercel.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.npmjs.com,www.npmjs.org,yarnpkg.com" - GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_API_URL: ${{ github.api_url }} - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/collect_ndjson_output.cjs'); - await main(); - - name: Upload sanitized agent output - if: always() && env.GH_AW_AGENT_OUTPUT - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: agent-output - path: ${{ env.GH_AW_AGENT_OUTPUT }} - if-no-files-found: warn - - name: Upload engine output files - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: agent_outputs - path: | - /tmp/gh-aw/sandbox/agent/logs/ - /tmp/gh-aw/redacted-urls.log - if-no-files-found: ignore - - name: Parse agent logs for step summary - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/parse_copilot_log.cjs'); - await main(); - - name: Parse MCP Gateway logs for step summary - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/parse_mcp_gateway_log.cjs'); - await main(); - - name: Print firewall logs - if: always() - continue-on-error: true - env: - AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs - run: | - # Fix permissions on firewall logs so they can be uploaded as artifacts - # AWF runs with sudo, creating files owned by root - sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true - # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) - if command -v awf &> /dev/null; then - awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" - else - echo 'AWF binary not installed, skipping firewall log summary' - fi - - name: Upload cache-memory data as artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - if: always() - with: - name: cache-memory - path: /tmp/gh-aw/cache-memory - - name: Upload agent artifacts - if: always() - continue-on-error: true - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: agent-artifacts - path: | - /tmp/gh-aw/aw-prompts/prompt.txt - /tmp/gh-aw/mcp-logs/ - /tmp/gh-aw/sandbox/firewall/logs/ - /tmp/gh-aw/agent-stdio.log - /tmp/gh-aw/agent/ - if-no-files-found: ignore - # --- Threat Detection (inline) --- - - name: Check if detection needed - id: detection_guard - if: always() - env: - OUTPUT_TYPES: ${{ steps.collect_output.outputs.output_types }} - HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} - run: | - if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then - echo "run_detection=true" >> "$GITHUB_OUTPUT" - echo "Detection will run: output_types=$OUTPUT_TYPES, has_patch=$HAS_PATCH" - else - echo "run_detection=false" >> "$GITHUB_OUTPUT" - echo "Detection skipped: no agent outputs or patches to analyze" - fi - - name: Clear MCP configuration for detection - if: always() && steps.detection_guard.outputs.run_detection == 'true' - run: | - rm -f /tmp/gh-aw/mcp-config/mcp-servers.json - rm -f /home/runner/.copilot/mcp-config.json - rm -f "$GITHUB_WORKSPACE/.gemini/settings.json" - - name: Prepare threat detection files - if: always() && steps.detection_guard.outputs.run_detection == 'true' - run: | - mkdir -p /tmp/gh-aw/threat-detection/aw-prompts - cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true - cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true - for f in /tmp/gh-aw/aw-*.patch; do - [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true - done - echo "Prepared threat detection files:" - ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true - - name: Setup threat detection - if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - WORKFLOW_NAME: "Linear Issue Triage Agent" - WORKFLOW_DESCRIPTION: "Triage new Linear issues for the Berlin Bureau (BER) team — classify type, assign priority, tag product area, and post reasoning comments." - HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/setup_threat_detection.cjs'); - await main(); - - name: Ensure threat-detection directory and log - if: always() && steps.detection_guard.outputs.run_detection == 'true' - run: | - mkdir -p /tmp/gh-aw/threat-detection - touch /tmp/gh-aw/threat-detection/detection.log - - name: Execute GitHub Copilot CLI - if: always() && steps.detection_guard.outputs.run_detection == 'true' - id: detection_agentic_execution - # Copilot CLI tool arguments (sorted): - # --allow-tool shell(cat) - # --allow-tool shell(grep) - # --allow-tool shell(head) - # --allow-tool shell(jq) - # --allow-tool shell(ls) - # --allow-tool shell(tail) - # --allow-tool shell(wc) - timeout-minutes: 20 - run: | - set -o pipefail - # shellcheck disable=SC1003 - sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,raw.githubusercontent.com,registry.npmjs.org,telemetry.enterprise.githubcopilot.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.23.0 --skip-pull --enable-api-proxy \ - -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(jq)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(wc)'\'' --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_DETECTION_COPILOT:+ --model "$GH_AW_MODEL_DETECTION_COPILOT"}' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log - env: - COPILOT_AGENT_RUNNER_TYPE: STANDALONE - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - GH_AW_MODEL_DETECTION_COPILOT: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }} - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GITHUB_API_URL: ${{ github.api_url }} - GITHUB_HEAD_REF: ${{ github.head_ref }} - GITHUB_REF_NAME: ${{ github.ref_name }} - GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} - GITHUB_WORKSPACE: ${{ github.workspace }} - XDG_CONFIG_HOME: /home/runner - - name: Parse threat detection results - id: parse_detection_results - if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/parse_threat_detection_results.cjs'); - await main(); - - name: Upload threat detection log - if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: threat-detection.log - path: /tmp/gh-aw/threat-detection/detection.log - if-no-files-found: ignore - - name: Set detection conclusion - id: detection_conclusion - if: always() - env: - RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} - DETECTION_SUCCESS: ${{ steps.parse_detection_results.outputs.success }} - run: | - if [[ "$RUN_DETECTION" != "true" ]]; then - echo "conclusion=skipped" >> "$GITHUB_OUTPUT" - echo "success=true" >> "$GITHUB_OUTPUT" - echo "Detection was not needed, marking as skipped" - elif [[ "$DETECTION_SUCCESS" == "true" ]]; then - echo "conclusion=success" >> "$GITHUB_OUTPUT" - echo "success=true" >> "$GITHUB_OUTPUT" - echo "Detection passed successfully" - else - echo "conclusion=failure" >> "$GITHUB_OUTPUT" - echo "success=false" >> "$GITHUB_OUTPUT" - echo "Detection found issues" - fi - - conclusion: - needs: - - activation - - agent - - safe_outputs - - update_cache_memory - if: (always()) && (needs.agent.result != 'skipped') - runs-on: ubuntu-slim - permissions: - contents: read - issues: write - outputs: - noop_message: ${{ steps.noop.outputs.noop_message }} - tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} - total_count: ${{ steps.missing_tool.outputs.total_count }} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@ce1794953e0ec42adc41b6fca05e02ab49ee21c3 # v0.68.3 - with: - destination: /opt/gh-aw/actions - - name: Download agent output artifact - continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: agent-output - path: /tmp/gh-aw/safeoutputs/ - - name: Setup agent output environment variable - run: | - mkdir -p /tmp/gh-aw/safeoutputs/ - find "/tmp/gh-aw/safeoutputs/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Process No-Op Messages - id: noop - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_NOOP_MAX: "1" - GH_AW_WORKFLOW_NAME: "Linear Issue Triage Agent" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/noop.cjs'); - await main(); - - name: Record Missing Tool - id: missing_tool - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Linear Issue Triage Agent" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/missing_tool.cjs'); - await main(); - - name: Handle Agent Failure - id: handle_agent_failure - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Linear Issue Triage Agent" - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} - GH_AW_WORKFLOW_ID: "linear-triage" - GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} - GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} - GH_AW_GROUP_REPORTS: "false" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/handle_agent_failure.cjs'); - await main(); - - name: Handle No-Op Message - id: handle_noop_message - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Linear Issue Triage Agent" - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} - GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }} - GH_AW_NOOP_REPORT_AS_ISSUE: "true" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/handle_noop_message.cjs'); - await main(); - - safe_outputs: - needs: agent - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.agent.outputs.detection_success == 'true') - runs-on: ubuntu-slim - permissions: - contents: read - issues: write - timeout-minutes: 15 - env: - GH_AW_ENGINE_ID: "copilot" - GH_AW_WORKFLOW_ID: "linear-triage" - GH_AW_WORKFLOW_NAME: "Linear Issue Triage Agent" - outputs: - code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} - code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} - create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} - create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} - created_issue_number: ${{ steps.process_safe_outputs.outputs.created_issue_number }} - created_issue_url: ${{ steps.process_safe_outputs.outputs.created_issue_url }} - process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} - process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@ce1794953e0ec42adc41b6fca05e02ab49ee21c3 # v0.68.3 - with: - destination: /opt/gh-aw/actions - - name: Download agent output artifact - continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: agent-output - path: /tmp/gh-aw/safeoutputs/ - - name: Setup agent output environment variable - run: | - mkdir -p /tmp/gh-aw/safeoutputs/ - find "/tmp/gh-aw/safeoutputs/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Process Safe Outputs - id: process_safe_outputs - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_ALLOWED_DOMAINS: "*.jsr.io,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,bun.sh,cdn.jsdelivr.net,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,deb.nodesource.com,deno.land,esm.sh,get.pnpm.io,github.com,googleapis.deno.dev,googlechromelabs.github.io,host.docker.internal,json-schema.org,json.schemastore.org,jsr.io,keyserver.ubuntu.com,mcp.linear.app,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.bower.io,registry.npmjs.com,registry.npmjs.org,registry.yarnpkg.com,repo.yarnpkg.com,s.symcb.com,s.symcd.com,security.ubuntu.com,skimdb.npmjs.com,storage.googleapis.com,telemetry.enterprise.githubcopilot.com,telemetry.vercel.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.npmjs.com,www.npmjs.org,yarnpkg.com" - GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"max\":1},\"missing_data\":{},\"missing_tool\":{}}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/safe_output_handler_manager.cjs'); - await main(); - - name: Upload safe output items manifest - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: safe-output-items - path: /tmp/safe-output-items.jsonl - if-no-files-found: warn - - update_cache_memory: - needs: agent - if: always() && needs.agent.outputs.detection_success == 'true' - runs-on: ubuntu-latest - permissions: {} - env: - GH_AW_WORKFLOW_ID_SANITIZED: lineartriage - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@ce1794953e0ec42adc41b6fca05e02ab49ee21c3 # v0.68.3 - with: - destination: /opt/gh-aw/actions - - name: Download cache-memory artifact (default) - id: download_cache_default - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - continue-on-error: true - with: - name: cache-memory - path: /tmp/gh-aw/cache-memory - - name: Check if cache-memory folder has content (default) - id: check_cache_default - shell: bash - run: | - if [ -d "/tmp/gh-aw/cache-memory" ] && [ "$(ls -A /tmp/gh-aw/cache-memory 2>/dev/null)" ]; then - echo "has_content=true" >> "$GITHUB_OUTPUT" - else - echo "has_content=false" >> "$GITHUB_OUTPUT" - fi - - name: Save cache-memory to cache (default) - if: steps.check_cache_default.outputs.has_content == 'true' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - key: memory-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ github.run_id }} - path: /tmp/gh-aw/cache-memory - diff --git a/.github/workflows/linear-triage.md b/.github/workflows/linear-triage.md deleted file mode 100644 index 1550fd8e8ce..00000000000 --- a/.github/workflows/linear-triage.md +++ /dev/null @@ -1,232 +0,0 @@ ---- -description: Triage new Linear issues for the Berlin Bureau (BER) team — classify type, assign priority, tag product area, and post reasoning comments. -on: - workflow_dispatch: - schedule: daily on weekdays -permissions: - contents: read -if: github.repository == 'TryGhost/Ghost' -tools: - cache-memory: true -mcp-servers: - linear: - command: "npx" - args: ["-y", "mcp-remote", "https://mcp.linear.app/mcp", "--header", "Authorization:Bearer ${{ secrets.LINEAR_API_KEY }}"] - env: - LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} -network: - allowed: - - defaults - - node - - mcp.linear.app -safe-outputs: - create-issue: - noop: ---- - -# Linear Issue Triage Agent - -You are an AI agent that triages new Linear issues for the **Berlin Bureau (BER)** team. Your goal is to reduce the time a human needs to complete triage by pre-classifying issues, assigning priority, tagging product areas, and recommending code investigations where appropriate. - -**You do not move issues out of Triage** — a human still makes the final call on status transitions. - -## Your Task - -1. Use the Linear MCP tools to find the BER team and list all issues currently in the **Triage** state -2. Check your cache-memory to see which issues you have already triaged — skip those -3. For each untriaged issue, apply the triage rubric below to: - - Classify the issue type - - Assign priority (both a priority label and Linear's built-in priority field) - - Tag the product area - - Post a triage comment explaining your reasoning -4. Update your cache-memory with the newly triaged issue IDs -5. After processing, call the `noop` safe output with a summary of what you did — e.g. "Triaged 1 issue: BER-3367 (Bug, P3)" or "No new BER issues in Triage state" if there was nothing to triage - -## Linear MCP Tools - -You have access to the official Linear MCP server. Use its tools to: - -- **Find issues**: Search for BER team issues in Triage state -- **Read issue details**: Get title, description, labels, priority, and comments -- **Update issues**: Add labels and set priority -- **Create comments**: Post triage reasoning comments - -Start by listing available tools to discover the exact tool names and parameters. - -**Important:** When updating labels, preserve existing labels. Fetch the issue's current labels first, then include both old and new label IDs in the update. - -## Cache-Memory Format - -Store and read a JSON file at the **exact path** `cache-memory/triage-cache.json`. Always use this filename — never rename it or create alternative files. - -```json -{ - "triaged_issue_ids": ["BER-3150", "BER-3151"], - "last_run": "2025-01-15T10:00:00Z" -} -``` - -On each run: -1. Read `cache-memory/triage-cache.json` to get previously triaged issue identifiers -2. Skip any issues already in the list -3. After processing, write the updated list back to `cache-memory/triage-cache.json` (append newly triaged IDs) - -## Triage Rubric - -### Decision 1: Type Classification - -Classify each issue based on its title, description, and linked context: - -| Type | Signal words / patterns | Label to apply | -|------|------------------------|----------------| -| **Bug** | "broken", "doesn't work", "regression", "error", "crash", stack traces, Sentry links, "unexpected behaviour" | `🐛 Bug` (`e51776f7-038e-474b-86ec-66981c9abb4f`) | -| **Security** | "vulnerability", "exploit", "bypass", "SSRF", "XSS", "injection", "authentication bypass", "2FA", CVE references | `🔒 Security` (`28c5afc1-8063-4e62-af11-e42d94591957`) — also apply Bug if applicable | -| **Feature** | "add support for", "it would be nice", "can we", "new feature", Featurebase links | `✨ Feature` (`db8672e2-1053-4bc7-9aab-9d38c5b01560`) | -| **Improvement** | "improve", "enhance", "optimise", "refactor", "clean up", "polish" | `🎨 Improvement` (`b36579e6-62e1-4f55-987d-ee1e5c0cde1a`) | -| **Performance** | "slow", "latency", "timeout", "memory", "CPU", "performance", load time complaints | `⚡️ Performance` (`9066d0ea-6326-4b22-b6f5-82fe7ce2c1d1`) | -| **Maintenance** | "upgrade dependency", "tech debt", "remove deprecated", "migrate" | `🛠️ Maintenance` (`0ca27922-3646-4ab7-bf03-e67230c0c39e`) | -| **Documentation** | "docs", "README", "guide", "tutorial", missing documentation | `📝 Documentation` (`25f8988a-5925-44cd-b0df-c0229463925f`) | - -If an issue matches multiple types (e.g. a security bug), apply all relevant labels. - -### Decision 2: Priority Assignment - -Assign priority to all issue types. Set both the Linear priority field and the corresponding priority label. - -**For bugs and security issues**, use these criteria: - -#### P1 — Urgent (Linear priority: 1, Label: `📊 Priority → P1 - Urgent` `11de115f-3e40-46c6-bf42-2aa2b9195cbd`) -- Security vulnerability with a clear exploit path -- Data loss or corruption (MySQL, disk) — actual or imminent (exception: small lexical data issues can be P2) -- Multiple customers' businesses immediately affected (broken payment collection, broken emails, broken member login) - -#### P2 — High (Linear priority: 2, Label: `📊 Priority → P2 - High` `aeda47fa-9db9-4f4d-a446-3cccf92c8d12`) -- Triggering monitoring alerts that wake on-call engineers (if recurring, bump to P1) -- Security vulnerability without a clear exploit -- Regression that breaks currently working core functionality -- Crashes the server or browser -- Significantly disrupts customers' members/end-users (e.g. incorrect pricing or access) -- Bugs with members, subscriptions, or newsletters without immediate business impact - -#### P3 — Medium (Linear priority: 3, Label: `📊 Priority → P3 - Medium` `10ec8b7b-725f-453f-b5d2-ff160d3b3c1e`) -- Bugs with members, subscriptions, or newsletters affecting only a few customers -- Bugs in recently released features that significantly affect usability -- Issues with setup/upgrade flows -- Broken features (dashboards, line charts, analytics, etc.) -- Correctness issues (e.g. timezones) - -#### P4 — Low (Linear priority: 4, Label: `📊 Priority → P4 - Low` `411a21ea-c8c0-4cb1-9736-7417383620ff`) -- Not quite working as expected, but little overall impact -- Not related to payments, email, or security -- Significantly more complex to fix than the value of fixing -- Purely cosmetic -- Has a clear and straightforward workaround - -**For non-bug issues** (features, improvements, performance, maintenance, documentation), assign a **provisional priority** based on estimated impact and urgency. Clearly mark it as provisional in the triage comment. - -#### Bump Modifiers - -**Bump UP one level if:** -- It causes regular alerts for on-call engineers -- It affects lots of users or VIP customers -- It prevents users from carrying out a critical use case or workflow -- It prevents rolling back to a previous release - -**Bump DOWN one level if:** -- Reported by a single, non-VIP user -- Only impacts an edge case or obscure use case - -Note in your comment if a bump modifier was applied and why. - -### Decision 3: Product Area Tagging - -Apply the most relevant `Product Area →` label: - -| Label | Covers | -|-------|--------| -| `Product Area → Editor` | Post/page editor, Koenig, Lexical, content blocks | -| `Product Area → Dashboard` | Admin dashboard, stats, overview | -| `Product Area → Analytics` | Analytics, charts, reporting | -| `Product Area → Memberships` | Member management, segmentation, member data | -| `Product Area → Portal` | Member-facing portal, signup/login flows | -| `Product Area → Newsletters` | Email newsletters, sending, email design | -| `Product Area → Admin` | General admin UI, settings, navigation | -| `Product Area → Settings area` | Settings screens specifically | -| `Product Area → Billing App` | Billing, subscription management | -| `Product Area → Themes` | Theme system, Handlebars, theme marketplace | -| `Product Area → Publishing` | Post publishing, scheduling, distribution | -| `Product Area → Growth` | Growth features, recommendations | -| `Product Area → Comments` | Comment system | -| `Product Area → Imports / Exports` | Data import/export | -| `Product Area → Welcome emails / Automations` | Automated emails, welcome sequences | -| `Product Area → Social Web` | ActivityPub, federation | -| `Product Area → i18n` | Internationalisation, translations | -| `Product Area → Sodo Search` | Search functionality | -| `Product Area → Admin-X Offers` | Offers system in Admin-X | - -If the issue spans multiple areas, apply all relevant labels. If no product area is clearly identifiable, don't force a label — note this in the comment. - -**Important:** Use the Linear MCP tools to look up product area label IDs before applying them. - -### Decision 4: Triage Comment - -Post a comment on the issue with your reasoning. Use this format: - -``` -🤖 **Automated Triage** - -**Type:** Bug (Security) -**Priority:** P2 — High -**Product Area:** Memberships -**Bump modifiers applied:** UP — affects multiple customers - -**Reasoning:** -This appears to be a security vulnerability in the session handling that could allow -2FA bypass. While no clear exploit path has been reported, the potential for -authentication bypass affecting all staff accounts warrants P2. Bumped up from P3 -because it affects all customers with 2FA enabled. - -**Recommended action:** Code investigation recommended — this is a security bug -that needs code-level analysis. -``` - -For non-bug issues, mark priority as provisional: - -``` -🤖 **Automated Triage** - -**Type:** Improvement -**Priority:** P3 — Medium *(provisional)* -**Product Area:** Admin -**Bump modifiers applied:** None - -**Reasoning:** -This is a refactoring task to share logic between two related functions. No user-facing -impact, but reduces maintenance burden for the retention offers codebase. Provisional -P3 based on moderate codebase impact and alignment with active project work. - -**Recommended action:** Code investigation recommended — small refactoring task with -clear scope, no design input needed. -``` - -### Decision 5: Code Investigation Recommendation - -Flag an issue for code investigation in your comment if **all** of these are true: - -1. Classified as a bug, security issue, performance issue, or small improvement/maintenance task -2. Does not require design input (no UI mockups needed, no UX decisions) -3. Has enough description to investigate (not just a title with no context) - -Do **not** recommend investigation for: -- Feature requests (need product/design input) -- Issues with vague descriptions and no reproduction steps — instead note "Needs more info" in the comment -- Issues that are clearly large architectural changes - -## Guidelines - -- Process issues one at a time, applying all decisions before moving to the next -- Be concise but include enough reasoning that a human can quickly validate or override -- When in doubt about classification, pick the closest match and note your uncertainty -- If an issue already has triage labels or a triage comment from a previous run, skip it -- Never move issues out of the Triage state -- After processing all issues, update cache-memory with the full list of triaged identifiers diff --git a/apps/admin-x-settings/src/components/settings/email/enable-newsletters.tsx b/apps/admin-x-settings/src/components/settings/email/enable-newsletters.tsx index f246b486fa3..c9facbd101f 100644 --- a/apps/admin-x-settings/src/components/settings/email/enable-newsletters.tsx +++ b/apps/admin-x-settings/src/components/settings/email/enable-newsletters.tsx @@ -35,8 +35,9 @@ const EnableNewsletters: React.FC<{ keywords: string[] }> = ({keywords}) => { const enableToggle = ( <> @@ -55,22 +56,24 @@ const EnableNewsletters: React.FC<{ keywords: string[] }> = ({keywords}) => { values={[ { key: 'private', - value: (newslettersEnabled !== 'disabled') ? (
+ value: (newslettersEnabled !== 'disabled' && !isDisabled) ? (
Enabled
- {isDisabled && - - Your is set to ‘Nobody’, only existing members will receive newsletters. - - }
) : -
- - Disabled +
+
+ + Disabled +
+ {isDisabled && + + Your is set to ‘Nobody’, which disables all newsletter sending. Change to ‘Invite-only’ to send newsletters to existing members without allowing new signups. + + }
} ]} diff --git a/apps/admin-x-settings/test/acceptance/membership/access.test.ts b/apps/admin-x-settings/test/acceptance/membership/access.test.ts index 3bcbafa7e52..d0d8f961b44 100644 --- a/apps/admin-x-settings/test/acceptance/membership/access.test.ts +++ b/apps/admin-x-settings/test/acceptance/membership/access.test.ts @@ -127,7 +127,7 @@ test.describe('Access settings', async () => { await expect(section.getByTestId('subscription-access-select')).toContainText('Nobody'); await expect(page.getByTestId('portal').getByRole('button', {name: 'Customize'})).toBeDisabled(); - await expect(page.getByTestId('enable-newsletters')).toContainText('only existing members will receive newsletters'); + await expect(page.getByTestId('enable-newsletters')).toContainText('which disables all newsletter sending'); }); test('Supports selecting specific tiers', async ({page}) => { diff --git a/apps/admin/package.json b/apps/admin/package.json index 9b03d82906c..159e3e5f95e 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -49,7 +49,7 @@ "tailwindcss": "^4.2.2", "typescript": "5.9.3", "typescript-eslint": "8.58.0", - "vite": "7.1.12", + "vite": "7.3.2", "vite-tsconfig-paths": "5.1.4", "vitest": "4.1.2" }, diff --git a/apps/portal/package.json b/apps/portal/package.json index 74ac87f0455..a817618c5c5 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/portal", - "version": "2.68.27", + "version": "2.68.28", "license": "MIT", "repository": "https://github.com/TryGhost/Ghost", "author": "Ghost Foundation", diff --git a/apps/portal/src/app.js b/apps/portal/src/app.js index 9abeed56b8b..11a241aa0bc 100644 --- a/apps/portal/src/app.js +++ b/apps/portal/src/app.js @@ -600,13 +600,17 @@ export default class App extends React.Component { if (qParams.get('stripe') === 'gift-purchase-success') { const token = qParams.get('gift_token'); - clearURLParams(['stripe', 'gift_token']); + const tierId = qParams.get('gift_tier'); + const cadence = qParams.get('gift_cadence'); + clearURLParams(['stripe', 'gift_token', 'gift_tier', 'gift_cadence']); if (token) { return { showPopup: true, page: 'giftSuccess', pageData: { - token + token, + tierId, + cadence } }; } diff --git a/apps/portal/src/components/pages/gift-page.js b/apps/portal/src/components/pages/gift-page.js index fb4b9b66964..d33f6032b0f 100644 --- a/apps/portal/src/components/pages/gift-page.js +++ b/apps/portal/src/components/pages/gift-page.js @@ -1,357 +1,637 @@ -import {useContext, useState} from 'react'; +import {useContext, useEffect, useRef, useState} from 'react'; import AppContext from '../../app-context'; import CloseButton from '../common/close-button'; -import SiteTitleBackButton from '../common/site-title-back-button'; import ActionButton from '../common/action-button'; import LoadingPage from './loading-page'; import {ReactComponent as CheckmarkIcon} from '../../images/icons/checkmark.svg'; -import {ReactComponent as GiftIcon} from '../../images/icons/gift.svg'; import {getAvailableProducts, getCurrencySymbol, formatNumber, getStripeAmount, isCookiesDisabled, getActiveInterval} from '../../utils/helpers'; -import calculateDiscount from '../../utils/discount'; +import useCardTilt from '../../utils/use-card-tilt'; // TODO: wrap strings with t() once copy is finalised /* eslint-disable i18next/no-literal-string */ export const GiftPageStyles = ` -.gh-portal-content.gift { - position: relative; - padding-top: 0; +.gh-portal-popup-container.full-size.gift, +.gh-portal-popup-container.full-size.giftSuccess, +.gh-portal-popup-container.full-size.giftRedemption { + padding: 0; + /* The default opacity/translate popup-in animation feels heavy on the + full-bleed 50/50 layout — let the page render in place instead. */ + animation: none; } -.gh-portal-gift-bg { - position: absolute; - top: 0; - left: 0; - right: 0; - height: 320px; - background: linear-gradient(180deg, var(--brandcolor), transparent); - opacity: 0.08; - pointer-events: none; - z-index: 0; +.gh-portal-content.gift, +.gh-portal-content.giftSuccess, +.gh-portal-content.giftRedemption { + position: relative; + padding: 0; + min-height: 100vh; } -.gh-portal-popup-wrapper.full-size .gh-portal-gift-bg { - top: -2vmin; - left: -6vmin; - right: -6vmin; - height: calc(320px + 2vmin); +.gh-portal-gift-checkout { + display: grid; + grid-template-columns: 1fr 1fr; + min-height: 100vh; + width: 100%; } -.gh-portal-gift-sitetitle { +.gh-portal-gift-checkout-left { position: relative; - z-index: 1; display: flex; align-items: center; justify-content: center; + background: var(--white); + padding: 64px 48px 128px; + overflow: hidden; +} + +.gh-portal-gift-checkout-bg { + display: none; +} + +.gh-portal-gift-checkout-inner { + position: relative; + z-index: 1; + width: 100%; + max-width: 496px; + display: flex; + flex-direction: column; +} + +.gh-portal-gift-checkout-header { + margin-bottom: 8px; +} + +.gh-portal-gift-checkout-header .gh-portal-main-title { + text-align: start; + margin: 0 0 12px; +} + +.gh-portal-gift-checkout-subtitle { + margin: 0; + font-size: 1.6rem; + line-height: 1.45em; + color: var(--grey3); +} + +.gh-portal-gift-checkout-section { + margin-top: 24px; +} + +.gh-portal-gift-checkout-label { + font-size: 1.2rem; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--grey6); + margin-bottom: 12px; +} + +.gh-portal-gift-checkout .gh-portal-products-pricetoggle { + margin: 0; +} + +.gh-portal-gift-checkout-tiers { + display: flex; + flex-direction: column; gap: 8px; - padding: 15px 32px 0; } -.gh-portal-gift-sitetitle-icon { - display: block; - width: 24px; - height: 24px; - border-radius: 4px; - background-position: 50%; - background-size: cover; - object-fit: cover; +.gh-portal-gift-checkout-tier { + display: flex; + align-items: center; + gap: 14px; + width: 100%; + background: var(--white); + border: 1px solid var(--grey11); + border-radius: 10px; + padding: 16px 20px; + cursor: pointer; + text-align: start; + transition: border-color 0.2s ease, background-color 0.2s ease; + font: inherit; + color: inherit; +} + +.gh-portal-gift-checkout-tier:hover { + border-color: var(--grey9); +} + +.gh-portal-gift-checkout-tier.selected { + border-color: var(--brandcolor); + background: color-mix(in srgb, var(--brandcolor) 6%, var(--white)); + box-shadow: 0 0 0 1px var(--brandcolor) inset; +} + +.gh-portal-gift-checkout-tier-radio { + flex-shrink: 0; + width: 18px; + height: 18px; + border-radius: 50%; + border: 1.5px solid var(--grey9); + background: var(--white); + position: relative; +} + +.gh-portal-gift-checkout-tier.selected .gh-portal-gift-checkout-tier-radio { + border-color: var(--brandcolor); + background: var(--brandcolor); } -.gh-portal-gift-sitetitle-name { +.gh-portal-gift-checkout-tier.selected .gh-portal-gift-checkout-tier-radio::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--white); + transform: translate(-50%, -50%); +} + +.gh-portal-gift-checkout-tier-name { + flex: 1; font-size: 1.5rem; font-weight: 500; color: var(--grey0); - line-height: 1; } -.gh-portal-gift-hero { - position: relative; - z-index: 1; +.gh-portal-gift-checkout-tier-price { + font-size: 1.5rem; + font-weight: 500; + color: var(--grey0); +} + +.gh-portal-gift-checkout-benefits { display: flex; flex-direction: column; - align-items: center; - text-align: center; - padding: 56px 32px 40px; + gap: 10px; + padding-left: 4px; } -.gh-portal-gift-hero-icon { - display: inline-flex; +.gh-portal-gift-checkout-benefit { + display: flex; + align-items: flex-start; + gap: 10px; + color: var(--grey1); + font-size: 1.45rem; + line-height: 1.4; +} + +.gh-portal-gift-checkout-benefit svg { + width: 14px; + height: 14px; + margin-top: 4px; + color: var(--grey1); + flex-shrink: 0; +} + +.gh-portal-gift-checkout .gh-portal-btn-primary { + border-radius: 999px; +} + +.gh-portal-gift-checkout-cta { + width: 100%; + height: 48px; + margin-top: 32px; + font-size: 1.5rem; + font-weight: 600; +} + +.gh-portal-gift-checkout-right { + position: sticky; + top: 0; + align-self: start; + height: 100vh; + display: flex; align-items: center; justify-content: center; - width: 48px; - height: 48px; - color: var(--brandcolor); - margin-bottom: 20px; + background: linear-gradient(to bottom, var(--white), color-mix(in srgb, var(--brandcolor) 5%, var(--white))); + padding: 64px 48px 128px; + overflow-y: auto; } -.gh-portal-gift-hero-icon svg { - width: 48px; - height: 48px; - stroke-width: 1.5; +.gh-portal-gift-checkout-card-stack { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + max-width: 440px; } -.gh-portal-gift-hero .gh-portal-main-title { - margin: 0 0 14px; +/* Wraps the card so we can tilt it forward when "Gift details" is expanded, + making the benefits feel like they're sliding out from behind the card. + Composes cleanly with the cursor-driven tilt applied to the card itself. */ +.gh-portal-gift-checkout-card-frame { + width: 100%; + transform-style: preserve-3d; + perspective: 1200px; + transition: transform 0.3s ease; } -.gh-portal-gift-hero .gh-portal-main-subtitle { - margin: 0; - font-size: 1.7rem; - line-height: 1.45em; +.gh-portal-gift-checkout-card-stack[data-revealing="true"] .gh-portal-gift-checkout-card-frame { + transform: rotate(3deg); +} + +.gh-portal-gift-checkout-card-benefits { + width: 100%; + margin-top: 28px; + overflow: hidden; + transition: height 0.3s ease; +} + +.gh-portal-gift-checkout-details-toggle { + display: inline-flex; + align-items: center; + gap: 4px; + margin-top: 24px; + padding: 8px 12px; + background: transparent; + border: none; + color: var(--grey5); + font-size: 1.4rem; + font-weight: 500; + cursor: pointer; + transition: color 0.15s ease; +} + +.gh-portal-gift-checkout-details-toggle:hover { color: var(--grey3); - text-align: center; } -.gh-portal-content.gift > section { +.gh-portal-gift-checkout-details-toggle svg { + width: 12px; + height: 12px; + transition: transform 0.2s ease; +} + +.gh-portal-gift-checkout-details-toggle.is-open svg { + transform: rotate(-180deg); +} + +.gh-portal-gift-checkout-details { + width: 100%; + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.3s ease, margin-top 0.3s ease; + margin-top: 0; +} + +.gh-portal-gift-checkout-details[data-open="true"] { + grid-template-rows: 1fr; + margin-top: 32px; +} + +.gh-portal-gift-checkout-details-inner { + overflow: hidden; +} + +.gh-portal-gift-checkout-card { position: relative; - z-index: 1; + width: 100%; + max-width: 440px; + aspect-ratio: 1.7 / 1; + background: linear-gradient(to top right, var(--white), color-mix(in srgb, var(--brandcolor) 8%, var(--white))); + border-radius: 32px; + padding: 28px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 24px 48px rgba(var(--blackrgb), 0.08), 0 4px 12px rgba(var(--blackrgb), 0.04); + display: flex; + flex-direction: column; + justify-content: space-between; + overflow: hidden; + transform-style: preserve-3d; + will-change: transform; } -.gh-portal-popup-wrapper.gift .gh-portal-btn-site-title-back { - background: transparent; - border-color: transparent; +.gh-portal-gift-checkout-card-site { + position: relative; + z-index: 2; + display: flex; + align-items: center; + gap: 8px; } -.gh-portal-popup-wrapper.gift .gh-portal-btn-site-title-back:hover { - border-color: transparent; +.gh-portal-gift-checkout-card-site-icon { + width: 24px; + height: 24px; + border-radius: 5px; + object-fit: cover; } -@media (max-width: 480px) { - .gh-portal-gift-hero { - padding: 40px 24px 32px; - } +.gh-portal-gift-checkout-card-site-name { + font-size: 1.4rem; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--grey0); +} - .gh-portal-gift-bg { - height: 240px; - } +.gh-portal-gift-checkout-card-meta { + position: relative; + z-index: 2; } -`; -function GiftProductCardBenefits({product}) { - if (!product.benefits || !product.benefits.length) { - return null; - } +.gh-portal-gift-checkout-card-duration { + font-size: 2.6rem; + font-weight: 600; + color: var(--grey0); + letter-spacing: -0.01em; + line-height: 1.1; +} - return ( -
- {product.benefits.map((benefit, idx) => { - const key = benefit?.id || `benefit-${idx}`; - - return ( -
- -
{benefit.name}
-
- ); - })} -
- ); +.gh-portal-gift-checkout-card-tier { + margin-top: 6px; + font-size: 1.4rem; + color: var(--grey3); + line-height: 1.3; } -function GiftProductCardPrice({product, selectedInterval}) { - const monthlyPrice = product.monthlyPrice; - const yearlyPrice = product.yearlyPrice; +/* Wrapped-present cross ribbon: vertical + horizontal straps, bow at intersection */ +.gh-portal-gift-checkout-card-ribbon-v, +.gh-portal-gift-checkout-card-ribbon-h { + position: absolute; + background: var(--brandcolor); + opacity: 0.3; + z-index: 1; +} - if (!monthlyPrice || !yearlyPrice) { - return null; - } +.gh-portal-gift-checkout-card-ribbon-v { + top: 0; + bottom: 0; + right: 22%; + width: 12px; +} - const activePrice = selectedInterval === 'month' ? monthlyPrice : yearlyPrice; - const currencySymbol = getCurrencySymbol(activePrice.currency); - const yearlyDiscount = calculateDiscount(monthlyPrice.amount, yearlyPrice.amount); +.gh-portal-gift-checkout-card-ribbon-h { + left: 0; + right: 0; + top: 32%; + height: 12px; + transform: translateY(-50%); +} - return ( -
-
-
- 1 ? ' long' : '')}>{currencySymbol} - {formatNumber(getStripeAmount(activePrice.amount))} -
- {selectedInterval === 'year' && yearlyDiscount > 0 && ( - {yearlyDiscount}% discount - )} -
- one-time payment -
- ); +.gh-portal-gift-checkout-card-bow { + position: absolute; + top: calc(32% - 42px); + right: calc(22% - 40px); + width: 90px; + height: 86px; + z-index: 2; + color: var(--brandcolor); + pointer-events: none; + filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.06)) drop-shadow(0 16px 32px rgba(0, 0, 0, 0.06)); } -function GiftProductCard({brandColor, product, selectedInterval, isDisabled, isPurchasing, onPurchase}) { - let productDescription = product.description; - if ((!product.benefits || !product.benefits.length) && !productDescription) { - productDescription = 'Full access'; +@media (max-width: 880px) { + .gh-portal-gift-checkout { + grid-template-columns: 1fr; + min-height: 0; } - return ( -
-
-

{product.name}

- -
-
-
- {productDescription && ( -
- {productDescription} -
- )} - -
-
- onPurchase(e, product)} - disabled={isDisabled} - isRunning={isPurchasing} - brandColor={brandColor} - style={{width: '100%'}} - /> -
-
-
- ); + /* Drop sticky/100vh sizing — on mobile the right column should sit naturally + above the left content, taking only the height it needs. */ + .gh-portal-gift-checkout-right { + order: -1; + position: static; + height: auto; + padding: 32px 24px; + overflow: visible; + } + + .gh-portal-gift-checkout-left { + padding: 32px 24px 80px; + } + + .gh-portal-gift-checkout-card, + .gh-portal-gift-checkout-card-stack { + max-width: 320px; + } +} + +@media (max-width: 480px) { + .gh-portal-gift-checkout-header .gh-portal-main-title { + font-size: 2.6rem; + } + + .gh-portal-gift-checkout-card-duration { + font-size: 2rem; + } } +`; -function GiftPriceSwitch({selectedInterval, setSelectedInterval, products}) { +function GiftPriceSwitch({selectedInterval, setSelectedInterval}) { const {site} = useContext(AppContext); const {portal_plans: portalPlans} = site; - const discounts = products.map(product => calculateDiscount(product.monthlyPrice?.amount, product.yearlyPrice?.amount)); - const highestDiscount = Math.max(...discounts); if (!portalPlans.includes('monthly') || !portalPlans.includes('yearly')) { return null; } return ( -
-
- - -
+
+ +
); } +function getTierPriceLabel(product, selectedInterval) { + const activePrice = selectedInterval === 'month' ? product.monthlyPrice : product.yearlyPrice; + + if (!activePrice) { + return ''; + } + + const currencySymbol = getCurrencySymbol(activePrice.currency); + return `${currencySymbol}${formatNumber(getStripeAmount(activePrice.amount))}`; +} + +function getDurationLabel(selectedInterval) { + return selectedInterval === 'month' ? '1 month' : '1 year'; +} + const GiftPage = () => { const {site, brandColor, action, doAction} = useContext(AppContext); const [selectedInterval, setSelectedInterval] = useState(null); - const [selectedProduct, setSelectedProduct] = useState(null); + const [selectedProductId, setSelectedProductId] = useState(null); + const benefitsInnerRef = useRef(null); + const [benefitsHeight, setBenefitsHeight] = useState(undefined); + const {cardRef, containerProps: cardTiltProps} = useCardTilt(); + + useEffect(() => { + const node = benefitsInnerRef.current; + if (!node || typeof ResizeObserver === 'undefined') { + return; + } + const observer = new ResizeObserver((entries) => { + setBenefitsHeight(entries[0].contentRect.height); + }); + observer.observe(node); + return () => observer.disconnect(); + }, []); if (!site) { return ; } const {portal_plans: portalPlans, portal_default_plan: portalDefaultPlan} = site; - const activeInterval = getActiveInterval({portalPlans, portalDefaultPlan, selectedInterval}); - const products = getAvailableProducts({site}).filter(p => p.type === 'paid'); const siteIcon = site.icon; const siteTitle = site.title || ''; - const giftPageHeader = ( - <> -
); diff --git a/apps/portal/src/components/pages/gift-redemption-page.js b/apps/portal/src/components/pages/gift-redemption-page.js index d958a97c09c..bf5d163f324 100644 --- a/apps/portal/src/components/pages/gift-redemption-page.js +++ b/apps/portal/src/components/pages/gift-redemption-page.js @@ -5,189 +5,27 @@ import CloseButton from '../common/close-button'; import InputForm from '../common/input-form'; import {ValidateInputForm} from '../../utils/form'; import {ReactComponent as CheckmarkIcon} from '../../images/icons/checkmark.svg'; -import {ReactComponent as GiftIcon} from '../../images/icons/gift.svg'; import {getGiftDurationLabel, getGiftRedemptionErrorMessage} from '../../utils/gift-redemption-notification'; import {t} from '../../utils/i18n'; import {hasGiftSubscriptions, removePortalLinkFromUrl} from '../../utils/helpers'; +import useCardTilt from '../../utils/use-card-tilt'; export const GiftRedemptionStyles = ` - .gh-portal-popup-container.giftRedemption { - width: calc(100vw - 24px); - max-width: 500px; - padding: 0; - overflow: hidden; - flex-shrink: 0; - } - - .gh-portal-popup-container.giftRedemption .gh-portal-closeicon-container { - position: absolute; - top: 16px; - right: 16px; - z-index: 5; - } - - html[dir="rtl"] .gh-portal-popup-container.giftRedemption .gh-portal-closeicon-container { - right: unset; - left: 18px; - } - - .gh-portal-popup-container.giftRedemption .gh-portal-closeicon { - color: rgba(24, 32, 38, 0.14); - } - - .gh-portal-popup-container.giftRedemption .gh-portal-closeicon:hover { - color: rgba(24, 32, 38, 0.28); - } - - .gh-portal-gift-redemption { - position: relative; - overflow: hidden; - } - - .gh-gift-redemption-bg { - position: absolute; - top: 0; - left: 0; - right: 0; - height: 400px; - background: linear-gradient(180deg, var(--brandcolor), transparent); - opacity: 0.08; - pointer-events: none; - z-index: 0; - } - - .gh-gift-redemption-panel { - position: relative; - z-index: 1; - padding: 40px 32px 32px; - } - - .gh-gift-redemption-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 48px; - height: 48px; - margin: -8px 0 0 -3px; - color: var(--brandcolor); - } - - html[dir="rtl"] .gh-gift-redemption-icon { - margin: -8px -3px 0 0; - } - - .gh-gift-redemption-icon svg { - width: 52px; - height: 52px; - } - - .gh-gift-redemption-kicker { - margin-top: 0; - font-size: 1.3rem; - font-weight: 600; - letter-spacing: 0.02em; - text-transform: uppercase; - color: var(--brandcolor); - } - - .gh-gift-redemption-title { - max-width: none; - margin: 20px 0 0; - font-size: 2.4rem; - font-weight: 700; - line-height: 1.3; - letter-spacing: -0.015em; - text-wrap: pretty; - color: var(--grey0); - } - - .gh-gift-redemption-plan { - margin-top: 8px; - font-size: 1.5rem; - color: var(--grey3); - } - - .gh-gift-redemption-tier { - font-weight: 700; - } - - .gh-gift-redemption-cadence { - font-weight: 400; - } - - .gh-gift-redemption-benefits { - display: flex; - flex-direction: column; - gap: 8px; - margin-top: 20px; - } - - .gh-gift-redemption-form { - margin-top: 36px; - } - - .gh-gift-redemption-inline-cta { - margin-top: 36px; - } - - .gh-gift-redemption-benefit { - display: flex; - align-items: flex-start; - gap: 10px; - color: var(--grey2); - font-size: 1.45rem; - line-height: 1.35; - text-align: left; - } - - .gh-gift-redemption-benefit svg { - width: 14px; - height: 14px; - margin-top: 3px; - color: var(--grey1); - flex-shrink: 0; - } - - html[dir="rtl"] .gh-gift-redemption-benefit { - text-align: right; - flex-direction: row-reverse; - } - - .gh-gift-redemption-submit { - width: 100%; - height: 44px; - margin-top: 20px; - font-size: 1.5rem; - font-weight: 600; - } - - @media (max-width: 480px) { - .gh-portal-popup-container.giftRedemption { - padding: 0 !important; - } - - .gh-gift-redemption-panel { - padding: 32px 24px 24px; - } - - .gh-gift-redemption-title { - font-size: 2.1rem; - } +.gh-portal-gift-redemption-form { + margin-top: 24px; +} - .gh-gift-redemption-benefit { - font-size: 1.4rem; - } - - html[dir="rtl"] .gh-gift-redemption-benefit { - text-align: right; - } - - .gh-gift-redemption-bg { - height: 300px; - } - } +.gh-portal-gift-redemption-form + .gh-portal-gift-checkout-cta { + margin-top: 16px; +} `; +const ChevronIcon = () => ( + +); + // TODO: Add translation strings once copy has been finalised const GiftRedemptionPage = () => { const {action, brandColor, doAction, member, pageData, site} = useContext(AppContext); @@ -197,6 +35,8 @@ const GiftRedemptionPage = () => { const [name, setName] = useState(member?.name || ''); const [email, setEmail] = useState(member?.email || ''); const [errors, setErrors] = useState({}); + const [showDetails, setShowDetails] = useState(false); + const {cardRef, containerProps: cardTiltProps} = useCardTilt(); useEffect(() => { setName(member?.name || ''); @@ -316,75 +156,111 @@ const GiftRedemptionPage = () => { const buttonLabel = isRedeeming ? 'Redeeming gift...' // TODO: Add translation strings once copy has been finalised : 'Redeem your membership'; // TODO: Add translation strings once copy has been finalised - const siteTitle = site?.title; + const siteIcon = site?.icon; + const siteTitle = site?.title || ''; const headerText = siteTitle ? `You've been gifted a membership to ${siteTitle}` : 'You\'ve been gifted a membership'; + const benefits = gift.tier.benefits || []; return ( -
+ <> -
- -
-
-
{'Gift membership'}
-

{headerText}

- -
- {gift.tier.name} -  ·  - {getGiftDurationLabel(gift)} -
- - {gift.tier.benefits.length > 0 && ( -
- {gift.tier.benefits.map((benefit, index) => { - const benefitName = typeof benefit === 'string' ? benefit : benefit?.name; - const benefitKey = typeof benefit === 'string' ? benefit : benefit?.id || `gift-benefit-${index}`; - - if (!benefitName) { - return null; - } - - return ( -
- - {benefitName} +
+
+
+ - )} - - {isLoggedIn ? ( -
- -
- ) : ( -
- - + +
+
+
+
+
+ {siteIcon && ( + + )} + {siteTitle} +
+
+
{getGiftDurationLabel(gift)}
+
{gift.tier.name}
+
+ + + {benefits.length > 0 && ( + <> +
+
+
+ {benefits.map((benefit, index) => { + const benefitName = typeof benefit === 'string' ? benefit : benefit?.name; + const benefitKey = typeof benefit === 'string' ? benefit : benefit?.id || `gift-benefit-${index}`; + + if (!benefitName) { + return null; + } + + return ( +
+ + {benefitName} +
+ ); + })} +
+
+
+ + + )} +
- )} +
-
+ ); }; diff --git a/apps/portal/src/components/pages/gift-success-page.js b/apps/portal/src/components/pages/gift-success-page.js index e57144a89d0..44b5c283ca9 100644 --- a/apps/portal/src/components/pages/gift-success-page.js +++ b/apps/portal/src/components/pages/gift-success-page.js @@ -1,115 +1,112 @@ import {useContext, useState} from 'react'; import AppContext from '../../app-context'; -import {ReactComponent as GiftIcon} from '../../images/icons/gift.svg'; import CloseButton from '../common/close-button'; import copyTextToClipboard from '../../utils/copy-to-clipboard'; +import {getAvailableProducts} from '../../utils/helpers'; +import {ReactComponent as CheckmarkIcon} from '../../images/icons/checkmark.svg'; +import useCardTilt from '../../utils/use-card-tilt'; // TODO: wrap strings with t() once copy is finalised /* eslint-disable i18next/no-literal-string */ export const GiftSuccessStyle = ` - .gh-portal-gift-success .gh-portal-signup-header { - margin-bottom: 0; - padding: 0; - } - - .gh-portal-gift-success .gh-gift-success-icon { - margin: 12px auto 0; - text-align: center; - color: var(--brandcolor); - width: 56px; - height: 56px; - } - - .gh-portal-gift-success .gh-gift-success-icon svg { - width: 56px; - height: 56px; - } - - .gh-portal-gift-success h1.gh-portal-main-title { - font-size: 32px; - margin-top: 16px; - } - - .gh-portal-gift-success .gh-portal-main-subtitle { - margin-top: 12px; - } - - .gh-portal-gift-success .gh-gift-link-container { - display: flex; - align-items: center; - height: 48px; - background-color: #f3f3f3; - border-radius: 8px; - padding: 6px 6px 6px 12px; - margin-top: 24px; - gap: 8px; - } - - .gh-portal-gift-success .gh-gift-link-url { - flex: 1; - font-size: 1.5rem; - color: var(--grey1); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; - user-select: all; - } - - .gh-portal-gift-success .gh-gift-copy-btn { - display: flex; - align-items: center; - gap: 4px; - height: 36px; - background: var(--brandcolor); - color: #fff; - border: none; - border-radius: 6px; - padding: 8px 16px 8px 14px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - white-space: nowrap; - flex-shrink: 0; - transition: opacity 0.15s ease; - will-change: opacity; - } - - .gh-portal-gift-success .gh-gift-copy-btn:hover { - opacity: 0.9; - } - - .gh-portal-gift-success .gh-gift-footer-text { - margin: 36px 0 0; - font-size: 1.3rem; - color: var(--grey7); - text-align: center; - line-height: 1.5; - } +.gh-portal-gift-success-link { + display: flex; + align-items: center; + height: 56px; + background: color-mix(in srgb, var(--brandcolor) 8%, var(--white)); + border-radius: 999px; + padding: 4px 8px 4px 24px; + gap: 8px; +} + +.gh-portal-gift-success-link-url { + flex: 1; + font-size: 1.6rem; + font-weight: 400; + color: var(--brandcolor); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + user-select: all; +} + +.gh-portal-gift-success-copy { + display: flex; + align-items: center; + gap: 6px; + height: 40px; + padding: 0 18px; + background: var(--brandcolor); + color: var(--white); + border: none; + border-radius: 999px; + font-size: 1.4rem; + font-weight: 600; + cursor: pointer; + flex-shrink: 0; + transition: opacity 0.15s ease; + will-change: opacity; +} + +.gh-portal-gift-success-copy:hover { + opacity: 0.9; +} + +.gh-portal-gift-success-copy svg { + width: 14px; + height: 14px; +} + +.gh-portal-gift-success-footer { + margin-top: 24px; + margin-bottom: 0; + font-size: 1.4rem; + color: var(--grey6); + line-height: 1.5; +} `; const CopyIcon = () => ( - + ); const CheckIcon = () => ( - + ); +const ChevronIcon = () => ( + +); + +function getDurationLabel(cadence) { + return cadence === 'month' ? '1 month' : '1 year'; +} + const GiftSuccessPage = () => { const {site, pageData} = useContext(AppContext); const [copied, setCopied] = useState(false); + const [showDetails, setShowDetails] = useState(false); + const {cardRef, containerProps: cardTiltProps} = useCardTilt(); const token = pageData?.token; + const tierId = pageData?.tierId; + const cadence = pageData?.cadence; const siteUrl = site?.url || ''; + const siteIcon = site?.icon; + const siteTitle = site?.title || ''; const redeemUrl = `${siteUrl.replace(/\/$/, '')}/gift/${token}`; + const products = getAvailableProducts({site}).filter(p => p.type === 'paid'); + const tier = tierId ? products.find(p => p.id === tierId) : null; + const handleCopy = () => { copyTextToClipboard(redeemUrl); setCopied(true); @@ -117,29 +114,98 @@ const GiftSuccessPage = () => { }; return ( -
+ <> - -
-
-

Your gift is ready!

-

- Send the link below to share it with whoever you'd like. -

-
- -
- {redeemUrl} - +
+
+
+ + +
+
+
+
+
+ {siteIcon && ( + + )} + {siteTitle} +
+ {tier && cadence && ( +
+
{getDurationLabel(cadence)}
+
{tier.name}
+
+ )} + + + {tier && tier.benefits && tier.benefits.length > 0 && ( + <> +
+
+
+ {tier.benefits.map((benefit, idx) => { + const key = benefit?.id || `benefit-${idx}`; + return ( +
+ + {benefit.name} +
+ ); + })} +
+
+
+ + + )} +
+
+
- -

- Not ready to share? We've also emailed a copy to your inbox. -

-
+ ); }; diff --git a/apps/portal/src/components/pages/magic-link-page.js b/apps/portal/src/components/pages/magic-link-page.js index 4b237d19a43..158a8746853 100644 --- a/apps/portal/src/components/pages/magic-link-page.js +++ b/apps/portal/src/components/pages/magic-link-page.js @@ -4,8 +4,16 @@ import CloseButton from '../common/close-button'; import InboxLinkButton from '../common/inbox-link-button'; import AppContext from '../../app-context'; import {ReactComponent as EnvelopeIcon} from '../../images/icons/envelope.svg'; +import {ReactComponent as CheckmarkIcon} from '../../images/icons/checkmark.svg'; import {isIos} from '../../utils/is-ios'; import {t} from '../../utils/i18n'; +import {getGiftDurationLabel} from '../../utils/gift-redemption-notification'; + +const ChevronIcon = () => ( + +); export const MagicLinkStyles = ` .gh-portal-icon-envelope { @@ -85,7 +93,8 @@ export default class MagicLinkPage extends React.Component { this.state = { [OTC_FIELD_NAME]: '', errors: {}, - isFocused: false + isFocused: false, + showDetails: false }; } @@ -301,9 +310,114 @@ export default class MagicLinkPage extends React.Component { ); } + renderGiftLayout(showOTCForm) { + const {site, pageData, otcRef} = this.context; + const gift = pageData?.gift; + const siteIcon = site?.icon; + const siteTitle = site?.title || ''; + const submittedEmailOrInbox = pageData?.email ? pageData.email : t('your inbox'); + const popupTitle = t('Now check your email!'); + const popupDescription = this.getTranslatedDescription({ + lastPage: 'gift', + otcRef, + submittedEmailOrInbox + }); + const benefits = gift.tier?.benefits || []; + + return ( + <> + +
+
+
+ +
+
+
+
+
+ {siteIcon && ( + + )} + {siteTitle} +
+
+
{getGiftDurationLabel(gift)}
+
{gift.tier?.name}
+
+ + + {benefits.length > 0 && ( + <> +
+
+
+ {benefits.map((benefit, index) => { + const benefitName = typeof benefit === 'string' ? benefit : benefit?.name; + const benefitKey = typeof benefit === 'string' ? benefit : benefit?.id || `gift-benefit-${index}`; + + if (!benefitName) { + return null; + } + + return ( +
+ + {benefitName} +
+ ); + })} +
+
+
+ + + )} +
+
+
+
+ + ); + } + render() { - const {otcRef} = this.context; + const {otcRef, lastPage, pageData} = this.context; const showOTCForm = !!otcRef; + const isGiftMode = lastPage === 'gift' && !!pageData?.gift; + + if (isGiftMode) { + return this.renderGiftLayout(showOTCForm); + } return (
diff --git a/apps/portal/src/components/popup-modal.js b/apps/portal/src/components/popup-modal.js index d8176ea5478..2e16dcdfb1b 100644 --- a/apps/portal/src/components/popup-modal.js +++ b/apps/portal/src/components/popup-modal.js @@ -146,7 +146,7 @@ class PopupContent extends React.Component { } render() { - const {page, pageQuery, site, customSiteUrl} = this.context; + const {page, pageQuery, site, customSiteUrl, lastPage} = this.context; const products = getSiteProducts({site, pageQuery}); const noOfProducts = products.length; @@ -189,11 +189,20 @@ class PopupContent extends React.Component { } } - if (page === 'gift') { + if (page === 'gift' || page === 'giftSuccess' || page === 'giftRedemption') { pageClass += ' full-size'; popupSize = 'full'; } + // Magic link page reached via gift redemption: render in the same + // 50/50 layout (gift card stays visible on the right) instead of as + // a small centered modal. Reuses the giftRedemption class so the + // existing full-size CSS rules apply. + if (page === 'magiclink' && lastPage === 'gift') { + pageClass += ' full-size giftRedemption'; + popupSize = 'full'; + } + const freeProduct = hasFreeProductPrice({site}); if ((freeProduct && noOfProducts > 2) || (!freeProduct && noOfProducts > 1)) { if (page === 'accountPlan') { diff --git a/apps/portal/src/utils/use-card-tilt.js b/apps/portal/src/utils/use-card-tilt.js new file mode 100644 index 00000000000..9b1f9241be1 --- /dev/null +++ b/apps/portal/src/utils/use-card-tilt.js @@ -0,0 +1,76 @@ +import {useCallback, useRef} from 'react'; + +/** + * Subtle 3D tilt effect that follows the cursor. + * + * Returns a `cardRef` to attach to the card element, and `containerProps` + * (mouse handlers) to spread onto the surrounding container that defines + * the active tracking area. + * + * Uses requestAnimationFrame to coalesce mousemove updates into one DOM write + * per frame, and toggles the transition duration so the card tracks the cursor + * responsively while moving and eases back smoothly on leave. Safari is more + * sensitive than Chrome to long transitions overlapping rapid style updates, + * so without this it feels like the tilt is debounced. + */ +export default function useCardTilt({maxTilt = 3, trackTransition = 'transform 80ms linear', restTransition = 'transform 400ms ease-out'} = {}) { + const cardRef = useRef(null); + const rafIdRef = useRef(null); + const targetRef = useRef({x: 0, y: 0}); + const isHoveringRef = useRef(false); + + const applyFrame = useCallback(() => { + rafIdRef.current = null; + const card = cardRef.current; + if (!card) { + return; + } + if (isHoveringRef.current) { + const {x, y} = targetRef.current; + card.style.transition = trackTransition; + card.style.transform = `perspective(1200px) rotateX(${-y * maxTilt}deg) rotateY(${x * maxTilt}deg)`; + } else { + card.style.transition = restTransition; + card.style.transform = ''; + } + }, [maxTilt, trackTransition, restTransition]); + + const schedule = useCallback(() => { + if (rafIdRef.current === null) { + rafIdRef.current = requestAnimationFrame(applyFrame); + } + }, [applyFrame]); + + const onMouseMove = useCallback((event) => { + const card = cardRef.current; + if (!card) { + return; + } + const rect = card.getBoundingClientRect(); + const halfWidth = rect.width / 2; + const halfHeight = rect.height / 2; + if (halfWidth === 0 || halfHeight === 0) { + return; + } + // Clamp to ±1: the mouse handlers are attached to the whole column, + // so the cursor can be well outside the card bounds — without + // clamping the rotation would exceed maxTilt near the column edges. + const clamp = value => Math.max(-1, Math.min(1, value)); + targetRef.current = { + x: clamp((event.clientX - rect.left - halfWidth) / halfWidth), + y: clamp((event.clientY - rect.top - halfHeight) / halfHeight) + }; + isHoveringRef.current = true; + schedule(); + }, [schedule]); + + const onMouseLeave = useCallback(() => { + isHoveringRef.current = false; + schedule(); + }, [schedule]); + + return { + cardRef, + containerProps: {onMouseMove, onMouseLeave} + }; +} diff --git a/apps/shade/.storybook/preview.tsx b/apps/shade/.storybook/preview.tsx index 2e3ff361296..27be90dfa23 100644 --- a/apps/shade/.storybook/preview.tsx +++ b/apps/shade/.storybook/preview.tsx @@ -58,6 +58,7 @@ const preview: Preview = { storySort: { method: 'alphabetical', order: [ + 'Foundations', 'Primitives', 'Components', 'Layout', diff --git a/apps/shade/package.json b/apps/shade/package.json index 66242970853..bd5cc275f8b 100644 --- a/apps/shade/package.json +++ b/apps/shade/package.json @@ -51,7 +51,7 @@ "sideEffects": false, "scripts": { "dev": "vite build --watch", - "build": "tsc -p tsconfig.declaration.json && vite build", + "build": "tsc -p tsconfig.declaration.json && tsc-alias -p tsconfig.declaration.json && vite build", "test": "pnpm test:types && vitest run --coverage", "test:unit": "pnpm test:types && vitest run", "test:types": "tsc --noEmit", @@ -100,6 +100,7 @@ "sinon": "18.0.1", "storybook": "10.3.5", "tailwindcss": "4.2.1", + "tsc-alias": "^1.8.17", "tw-animate-css": "1.4.0", "typescript": "5.9.3", "vite": "5.4.21", diff --git a/apps/shade/src/components.ts b/apps/shade/src/components.ts index 3f73e4eb64a..ab46c0000f4 100644 --- a/apps/shade/src/components.ts +++ b/apps/shade/src/components.ts @@ -18,7 +18,6 @@ export * from './components/ui/empty-indicator'; export * from './components/ui/field'; export * from './components/ui/flag'; export * from './components/ui/form'; -export * from './components/ui/gh-chart'; export * from './components/ui/hover-card'; export * from './components/ui/indicator'; export * from './components/ui/input'; @@ -26,6 +25,7 @@ export * from './components/ui/input-group'; export * from './components/ui/kbd'; export * from './components/ui/label'; export * from './components/ui/loading-indicator'; +export * from './components/ui/metric-value'; export * from './components/ui/multi-select-combobox'; export * from './components/ui/navbar'; export * from './components/ui/no-value-label'; @@ -39,12 +39,19 @@ export * from './components/ui/sheet'; export * from './components/ui/sidebar'; export * from './components/ui/skeleton'; export * from './components/ui/sonner'; +export * from './components/ui/input-surface'; export * from './components/ui/switch'; export * from './components/ui/table'; export * from './components/ui/tabs'; export * from './components/ui/textarea'; export * from './components/ui/toggle-group'; export * from './components/ui/tooltip'; +export * from './components/ui/trend-badge'; + +// Feature components — product-shaped compositions of UI primitives +export * from './components/features/charts/gh-chart'; +export * from './components/features/kpi/kpi-card'; +export * from './components/features/kpi/kpi-tabs'; export type {DropdownMenuCheckboxItemProps as DropdownMenuCheckboxItemProps} from '@radix-ui/react-dropdown-menu'; diff --git a/apps/shade/src/components/ui/gh-chart.stories.tsx b/apps/shade/src/components/features/charts/gh-chart.stories.tsx similarity index 100% rename from apps/shade/src/components/ui/gh-chart.stories.tsx rename to apps/shade/src/components/features/charts/gh-chart.stories.tsx diff --git a/apps/shade/src/components/ui/gh-chart.tsx b/apps/shade/src/components/features/charts/gh-chart.tsx similarity index 99% rename from apps/shade/src/components/ui/gh-chart.tsx rename to apps/shade/src/components/features/charts/gh-chart.tsx index f489c85702c..467ddd9d4eb 100644 --- a/apps/shade/src/components/ui/gh-chart.tsx +++ b/apps/shade/src/components/features/charts/gh-chart.tsx @@ -1,6 +1,6 @@ import {calculateYAxisWidth, cn, formatDisplayDateWithRange, formatNumber, getYRange} from '@/lib/utils'; import React from 'react'; -import {AlignedAxisTick, ChartConfig, ChartContainer, ChartTooltip} from './chart'; +import {AlignedAxisTick, ChartConfig, ChartContainer, ChartTooltip} from '@/components/ui/chart'; import {Area, AreaChart, CartesianGrid, XAxis, YAxis} from 'recharts'; import {TrendingDown, TrendingUp} from 'lucide-react'; diff --git a/apps/shade/src/components/ui/filters.stories.tsx b/apps/shade/src/components/features/filters/filters.stories.tsx similarity index 100% rename from apps/shade/src/components/ui/filters.stories.tsx rename to apps/shade/src/components/features/filters/filters.stories.tsx diff --git a/apps/shade/src/components/ui/filters.tsx b/apps/shade/src/components/features/filters/filters.tsx similarity index 100% rename from apps/shade/src/components/ui/filters.tsx rename to apps/shade/src/components/features/filters/filters.tsx diff --git a/apps/shade/src/components/features/kpi/kpi-card.stories.tsx b/apps/shade/src/components/features/kpi/kpi-card.stories.tsx new file mode 100644 index 00000000000..90ac2298adf --- /dev/null +++ b/apps/shade/src/components/features/kpi/kpi-card.stories.tsx @@ -0,0 +1,177 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {Coins, Eye, User} from 'lucide-react'; + +import {Button} from '@/components/ui/button'; +import {Card, CardContent} from '@/components/ui/card'; +import {KpiCardHeader, KpiCardHeaderLabel, KpiCardHeaderValue} from './kpi-card'; + +const meta = { + title: 'Features / KPI / Card', + component: KpiCardHeaderValue, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: 'Card header layout for displaying a KPI metric with optional trend badge. Composes `MetricValue` and `TrendBadge` from the Components layer. Use inside a `Card` (from Components / Card).' + } + } + } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const WithUpTrend: Story = { + render: () => ( + + +
+ + + Unique visitors + + +
+
+ Chart placeholder content +
+ ) +}; + +export const WithTrendTooltip: Story = { + render: () => ( + + +
+ + + Unique visitors + + +
+
+ Chart placeholder content +
+ ) +}; + +export const WithDownTrend: Story = { + render: () => ( + + +
+ + + Members + + +
+
+ Chart placeholder content +
+ ) +}; + +export const WithColorIndicator: Story = { + render: () => ( + + +
+ + + + MRR + + +
+
+ Chart placeholder content +
+ ) +}; + +export const NoTrend: Story = { + render: () => ( + + +
+ + + Page views + + +
+
+ Chart placeholder content +
+ ) +}; + +export const WithHoverButton: Story = { + render: () => ( + + +
+ + + + Members + + +
+ +
+ Chart placeholder content +
+ ) +}; + +export const HiddenTrend: Story = { + render: () => ( + + +
+ + + All-time revenue + + +
+
+ Chart placeholder content +
+ ) +}; diff --git a/apps/shade/src/components/features/kpi/kpi-card.tsx b/apps/shade/src/components/features/kpi/kpi-card.tsx new file mode 100644 index 00000000000..1e06da199cc --- /dev/null +++ b/apps/shade/src/components/features/kpi/kpi-card.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; +import {cn} from '@/lib/utils'; +import {MetricValue} from '@/components/ui/metric-value'; +import {TrendBadge} from '@/components/ui/trend-badge'; + +const KpiCardHeader: React.FC> = ({children, className, ...props}) => { + return ( +
+ {children} +
+ ); +}; + +const KpiCardHeaderLabel: React.FC> = ({children, className, color, ...props}) => { + return ( +
+ {color &&
} + {children} +
+ ); +}; + +interface KpiCardValueProps { + value: string | number; + diffDirection?: 'up' | 'down' | 'same' | 'empty' | 'hidden'; + diffValue?: string | number; + diffTooltip?: React.ReactNode; +} + +const KpiCardHeaderValue: React.FC = ({value, diffDirection, diffValue, diffTooltip}) => { + let trailing: React.ReactNode = null; + if (diffDirection && diffDirection !== 'hidden') { + if (diffDirection === 'empty') { + // Reserves the same vertical space as a real trend badge without showing one. + trailing = ( +
+ {diffValue} +
+ ); + } else { + trailing = ( + + ); + } + } + return ( + + ); +}; + +export {KpiCardHeader, KpiCardHeaderLabel, KpiCardHeaderValue}; diff --git a/apps/shade/src/components/features/kpi/kpi-tabs.stories.tsx b/apps/shade/src/components/features/kpi/kpi-tabs.stories.tsx new file mode 100644 index 00000000000..e7692aef1a5 --- /dev/null +++ b/apps/shade/src/components/features/kpi/kpi-tabs.stories.tsx @@ -0,0 +1,253 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; + +import {Tabs, TabsContent, TabsList} from '@/components/ui/tabs'; +import {KpiTabTrigger, KpiTabValue} from './kpi-tabs'; + +const meta = { + title: 'Features / KPI / Tabs', + component: KpiTabValue, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: 'KPI tabs for analytics dashboards. Each trigger renders a metric label, value, and optional trend badge. Wraps the generic Tabs primitive with `variant=\'kpis\'` styling.' + } + } + }, + decorators: [ + Story => ( +
+ +
+ ) + ] +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + render: () => ( + + + + + + + + + + + + + +
Signups analytics and charts would go here.
+
+ +
Revenue analytics and charts would go here.
+
+ +
Engagement analytics and charts would go here.
+
+
+ ), + parameters: { + docs: { + description: { + story: 'KPI tabs displaying metrics with trend indicators - perfect for dashboard analytics.' + } + } + } +}; + +export const WithIcons: Story = { + render: () => ( + + + + + + + + + + + + + + + + +
Active users analytics would go here.
+
+ +
Page views analytics would go here.
+
+ +
Bounce rate analytics would go here.
+
+ +
Conversion analytics would go here.
+
+
+ ), + parameters: { + docs: { + description: { + story: 'KPI tabs enhanced with icons for better visual recognition of different metrics.' + } + } + } +}; + +export const WithColors: Story = { + render: () => ( + + + + + + + + + + + + + + + + +
Organic traffic breakdown would go here.
+
+ +
Paid traffic breakdown would go here.
+
+ +
Social traffic breakdown would go here.
+
+ +
Direct traffic breakdown would go here.
+
+
+ ), + parameters: { + docs: { + description: { + story: 'KPI tabs with color-coded indicators to categorize different data sources or types.' + } + } + } +}; + +export const WithoutTrends: Story = { + render: () => ( + + + + + + + + + + + + + +
Subscriber details would go here.
+
+ +
Posts overview would go here.
+
+ +
Comments overview would go here.
+
+
+ ), + parameters: { + docs: { + description: { + story: 'KPI tabs showing absolute values without trend indicators - useful for cumulative or static metrics.' + } + } + } +}; diff --git a/apps/shade/src/components/features/kpi/kpi-tabs.tsx b/apps/shade/src/components/features/kpi/kpi-tabs.tsx new file mode 100644 index 00000000000..a95508b02d9 --- /dev/null +++ b/apps/shade/src/components/features/kpi/kpi-tabs.tsx @@ -0,0 +1,136 @@ +import * as React from 'react'; +import * as LucideIcons from 'lucide-react'; +import {type LucideIcon} from 'lucide-react'; + +import {cn} from '@/lib/utils'; +import {Button, ButtonProps} from '@/components/ui/button'; +import {MetricValue} from '@/components/ui/metric-value'; +import {Tabs, TabsContent, TabsList, TabsProps, TabsTrigger} from '@/components/ui/tabs'; +import {TrendBadge} from '@/components/ui/trend-badge'; + +/** + * KPI variant of the Tabs primitives. Wraps the generic Tabs / TabsList / + * TabsTrigger / TabsContent and pins the `kpis` cva variant so consumers don't + * have to pass `variant='kpis'` themselves. The `kpis` cva variant lives in + * `ui/tabs.tsx` as a private implementation detail used only here. + */ +const KpiTabs = React.forwardRef< + React.ElementRef, + Omit +>(({...props}, ref) => ( + +)); +KpiTabs.displayName = 'KpiTabs'; + +const KpiTabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); +KpiTabsList.displayName = 'KpiTabsList'; + +const KpiTabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); +KpiTabsContent.displayName = 'KpiTabsContent'; + +interface KpiTabTriggerProps extends React.ComponentProps { + children: React.ReactNode; +} + +const KpiTabTrigger: React.FC = ({children, ...props}) => { + return ( + + {children} + + ); +}; + +interface KpiTabValueProps { + color?: string; + icon?: keyof typeof LucideIcons; + label: string; + value: string | number; + diffDirection?: 'up' | 'down' | 'same' | 'hidden'; + diffValue?: string | number; + className?: string; + 'data-testid'?: string; +} + +const KpiTabValue: React.FC = ({ + color, + icon: iconName, + label, + value, + diffDirection, + diffValue, + className, + 'data-testid': testId +}) => { + const IconComponent = iconName ? LucideIcons[iconName] as LucideIcon : null; + + const labelNode = ( + + {color &&
} + {IconComponent && } + {label} +
+ ); + + const trailing = diffDirection && diffDirection !== 'hidden' ? ( + + ) : null; + + return ( + + ); +}; + +interface KpiDropdownButtonProps extends ButtonProps { + className?: string; + children: React.ReactNode; +} + +const KpiDropdownButton = React.forwardRef( + ({variant = 'dropdown', className, ...props}, ref) => { + return ( + - , - - Chart placeholder content - - ] - } -}; - -export const KpiCardHiddenTrend: Story = { - args: { - className: 'w-[350px]', - children: [ - -
- - - All-time revenue - - -
-
, - - Chart placeholder content - - ] - } -}; diff --git a/apps/shade/src/components/ui/card.tsx b/apps/shade/src/components/ui/card.tsx index 5a3540343ce..a0f0e81dca3 100644 --- a/apps/shade/src/components/ui/card.tsx +++ b/apps/shade/src/components/ui/card.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import {cn} from '@/lib/utils'; import {cva} from 'class-variance-authority'; -import {TrendingDown, TrendingUp} from 'lucide-react'; type CardsVariant = 'outline' | 'plain'; const CardsVariantContext = React.createContext('outline'); @@ -151,71 +150,6 @@ const CardFooter = React.forwardRef< }); CardFooter.displayName = 'CardFooter'; -const KpiCardHeader: React.FC> = ({children, className, ...props}) => { - return ( -
- {children} -
- ); -}; - -const KpiCardHeaderLabel: React.FC> = ({children, className, color, ...props}) => { - return ( -
- {color &&
} - {children} -
- ); -}; - -interface KpiCardValueProps { - value: string | number; - diffDirection?: 'up' | 'down' | 'same' | 'empty' | 'hidden'; - diffValue?: string | number; - diffTooltip?: React.ReactNode; -} - -const KpiCardHeaderValue: React.FC = ({value, diffDirection, diffValue, diffTooltip}) => { - const diffContainerClassName = cn( - 'flex items-center gap-1 text-xs h-[22px] px-1.5 rounded-xs group/diff cursor-default', - diffDirection === 'up' && `text-state-success bg-state-success/10 ${diffTooltip && 'hover:bg-state-success/20'}`, - diffDirection === 'down' && `text-state-danger bg-state-danger/10 ${diffTooltip && 'hover:bg-state-danger/20'}`, - diffDirection === 'same' && 'text-text-secondary bg-muted' - ); - return ( -
-
- {value} -
- {diffDirection && diffDirection !== 'hidden' && - <> -
- {diffValue} - {diffDirection === 'up' && - - } - {diffDirection === 'down' && - - } - {diffTooltip && -
- {diffTooltip} -
- } -
- - } -
- ); -}; - // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface EmptyCardProps extends React.ComponentPropsWithoutRef<'div'> {} @@ -238,9 +172,6 @@ export { CardTitle, CardDescription, CardContent, - KpiCardHeader, - KpiCardHeaderLabel, - KpiCardHeaderValue, EmptyCard, cardVariants }; diff --git a/apps/shade/src/components/ui/input-group.tsx b/apps/shade/src/components/ui/input-group.tsx index b08d86d8dbf..9d55c05875a 100644 --- a/apps/shade/src/components/ui/input-group.tsx +++ b/apps/shade/src/components/ui/input-group.tsx @@ -4,13 +4,19 @@ import {cva, type VariantProps} from 'class-variance-authority'; import {cn} from '@/lib/utils'; import {Button} from '@/components/ui/button'; import {Input} from '@/components/ui/input'; +import {inputSurfaceClasses} from '@/components/ui/input-surface'; import {Textarea} from '@/components/ui/textarea'; function InputGroup({className, ...props}: React.ComponentProps<'div'>) { return (
textarea]:h-auto', // Variants based on alignment. @@ -19,12 +25,11 @@ function InputGroup({className, ...props}: React.ComponentProps<'div'>) { 'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3', 'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3', - // Focus state. + // Focus state — scoped to the input-group control specifically so that + // focusing an InputGroupButton inside the group does NOT trigger the surface + // focus ring. This is why we don't use inputSurface('within') here. 'has-[[data-slot=input-group-control]:focus-visible]:outline-hidden has-[[data-slot=input-group-control]:focus-visible]:bg-transparent has-[[data-slot=input-group-control]:focus-visible]:border-focus-ring has-[[data-slot=input-group-control]:focus-visible]:ring-2 has-[[data-slot=input-group-control]:focus-visible]:ring-focus-ring/25', - // Error state. - 'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40', - className )} data-slot="input-group" diff --git a/apps/shade/src/components/ui/input-surface.stories.tsx b/apps/shade/src/components/ui/input-surface.stories.tsx new file mode 100644 index 00000000000..9dd2acca304 --- /dev/null +++ b/apps/shade/src/components/ui/input-surface.stories.tsx @@ -0,0 +1,200 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {cn} from '@/lib/utils'; +import {inputSurface, inputSurfaceClasses} from './input-surface'; + +/** + * Docs-only Storybook entry for the `inputSurface` recipe. + * Lives under "Foundations" rather than "Components" because there's no component to render — + * just a shared visual recipe that powers Input, Textarea, InputGroup and the Select trigger. + */ +const meta: Meta = { + title: 'Foundations / Input Surface', + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: + 'Shared visual recipe for input-like surfaces. The same border, background, radius, focus ring, and invalid state used by every form control in Shade. Use `inputSurface(\'self\')` when applying directly to a focusable element (``, `